Merge branch 'main' into feat/ai-prompt-char-counter

This commit is contained in:
Paulo Castellano 2026-07-17 10:46:44 -03:00 committed by GitHub
commit a9628b7e8d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
426 changed files with 39128 additions and 445 deletions

View file

@ -191,8 +191,11 @@ STRIPE_SECRET=
STRIPE_WEBHOOK_SECRET=
# Set to false to allow generic signup trial without requiring a card.
REQUIRE_CARD_FOR_TRIAL=true
# Free trial length in days (Stripe Checkout shows one day fewer).
# Free trial length in days, used only for the no-card generic trial above.
CASHIER_TRIAL_DAYS=8
# Stripe Coupon ID (amount_off, duration=once) so the first invoice at
# checkout comes out to $1 instead of the full monthly price.
STRIPE_FIRST_MONTH_COUPON_ID=
# Stripe Plan Price IDs (one per plan × interval). Used by PlanSeeder.
STRIPE_WORKSPACE_MONTHLY=

View file

@ -0,0 +1,65 @@
name: 'Setup Laravel test environment'
description: 'Set up PHP, Composer, optional Node, the app key, database, and Passport keys for a test job.'
inputs:
node:
description: 'Also set up Node.js and install npm dependencies.'
default: 'false'
db-port:
description: 'Host port mapped to the Postgres service.'
required: true
redis-port:
description: 'Host port mapped to the Redis service.'
required: true
runs:
using: 'composite'
steps:
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, gd, redis
coverage: none
- name: Setup Node.js
if: inputs.node == 'true'
uses: actions/setup-node@v6
with:
node-version: '22'
cache: 'npm'
- name: Cache Composer dependencies
uses: actions/cache@v6
with:
path: vendor
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
restore-keys: ${{ runner.os }}-composer-
- name: Prepare environment
shell: bash
run: cp .env.ci .env
- name: Install Composer dependencies
shell: bash
run: composer install --no-interaction --prefer-dist --optimize-autoloader
- name: Install npm dependencies
if: inputs.node == 'true'
shell: bash
run: npm ci
- name: Generate application key
shell: bash
run: php artisan key:generate
- name: Run migrations
shell: bash
env:
DB_PORT: ${{ inputs.db-port }}
REDIS_PORT: ${{ inputs.redis-port }}
run: php artisan migrate --force
- name: Generate Passport keys
shell: bash
run: php artisan passport:keys --force

View file

@ -17,7 +17,7 @@ jobs:
quality:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- name: Setup PHP
uses: shivammathur/setup-php@v2

View file

@ -36,7 +36,7 @@ jobs:
platform: ${{ matrix.platform }}
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Docker metadata (labels)
id: meta
@ -76,7 +76,7 @@ jobs:
touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: digests-${{ env.PLATFORM_PAIR }}
path: ${{ runner.temp }}/digests/*
@ -92,7 +92,7 @@ jobs:
packages: write
steps:
- name: Download digests
uses: actions/download-artifact@v4
uses: actions/download-artifact@v7
with:
path: ${{ runner.temp }}/digests
pattern: digests-*

View file

@ -6,18 +6,19 @@ on:
pull_request:
branches: [main, develop]
env:
DB_CONNECTION: pgsql
DB_DATABASE: trypost_test
DB_USERNAME: postgres
DB_PASSWORD: password
BROADCAST_CONNECTION: "null"
CACHE_STORE: array
QUEUE_CONNECTION: sync
SESSION_DRIVER: array
jobs:
tests:
backend:
runs-on: ubuntu-latest
env:
DB_CONNECTION: pgsql
DB_DATABASE: trypost_test
DB_USERNAME: postgres
DB_PASSWORD: password
BROADCAST_CONNECTION: "null"
CACHE_STORE: array
QUEUE_CONNECTION: sync
SESSION_DRIVER: array
services:
postgres:
@ -46,54 +47,89 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Setup PHP
uses: shivammathur/setup-php@v2
- name: Setup test environment
uses: ./.github/actions/setup-laravel
with:
php-version: '8.4'
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, gd, redis
coverage: none
db-port: ${{ job.services.postgres.ports['5432'] }}
redis-port: ${{ job.services.redis.ports['6379'] }}
- name: Setup Node.js
uses: actions/setup-node@v4
- name: Run backend tests
env:
DB_PORT: ${{ job.services.postgres.ports['5432'] }}
REDIS_PORT: ${{ job.services.redis.ports['6379'] }}
run: php artisan test --compact --parallel
e2e:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [1, 2]
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
POSTGRES_DB: trypost_test
ports:
- 5432/tcp
options: >-
--health-cmd="pg_isready"
--health-interval=10s
--health-timeout=5s
--health-retries=3
redis:
image: redis:7
ports:
- 6379/tcp
options: >-
--health-cmd="redis-cli ping"
--health-interval=10s
--health-timeout=5s
--health-retries=3
steps:
- name: Checkout code
uses: actions/checkout@v7
- name: Setup test environment
uses: ./.github/actions/setup-laravel
with:
node-version: '22'
cache: 'npm'
- name: Cache Composer dependencies
uses: actions/cache@v4
with:
path: vendor
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
restore-keys: ${{ runner.os }}-composer-
- name: Prepare environment
run: cp .env.ci .env
- name: Install Composer dependencies
run: composer install --no-interaction --prefer-dist --optimize-autoloader
- name: Install npm dependencies
run: npm ci
- name: Generate application key
run: php artisan key:generate
node: 'true'
db-port: ${{ job.services.postgres.ports['5432'] }}
redis-port: ${{ job.services.redis.ports['6379'] }}
- name: Build assets
run: npm run build
- name: Run migrations
run: php artisan migrate --force
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
- name: Run browser tests
env:
DB_PORT: ${{ job.services.postgres.ports['5432'] }}
REDIS_PORT: ${{ job.services.redis.ports['6379'] }}
run: php artisan test tests/Browser --compact --shard=${{ matrix.shard }}/2
- name: Generate Passport keys
run: php artisan passport:keys --force
- name: Upload Playwright artifacts
if: failure()
uses: actions/upload-artifact@v7
with:
name: playwright-artifacts-${{ matrix.shard }}
path: tests/Browser/Screenshots
if-no-files-found: ignore
retention-days: 7
- name: Run tests
run: php artisan test --compact --parallel
env:
DB_PORT: ${{ job.services.postgres.ports['5432'] }}
REDIS_PORT: ${{ job.services.redis.ports['6379'] }}
e2e-gate:
if: always()
needs: e2e
runs-on: ubuntu-latest
steps:
- name: Require all e2e shards to pass
run: '[ "${{ needs.e2e.result }}" = "success" ] || exit 1'

1
.gitignore vendored
View file

@ -1,6 +1,7 @@
/.phpunit.cache
/bootstrap/ssr
/node_modules
/tests/Browser/Screenshots
/public/build
/lang/php_*.json
/public/hot

View file

@ -5,6 +5,7 @@
namespace App\Actions\Billing;
use App\Models\Account;
use App\Support\Billing\FirstMonthCheckoutDiscount;
use Inertia\Inertia;
use Symfony\Component\HttpFoundation\Response;
@ -12,8 +13,9 @@ class StartSubscriptionCheckout
{
/**
* Create a Stripe Checkout session for the given price and return an Inertia
* redirect to it. Quantity tracks the account's workspace count; a trial is
* attached when the instance requires a card up front.
* redirect to it. Quantity tracks the account's workspace count; the first
* month's coupon is applied when the instance requires a card up front, so
* the first invoice charges $1 instead of running a $0 trial authorization.
*/
public function redirect(Account $account, string $priceId, string $cancelUrl): Response
{
@ -23,12 +25,9 @@ public function redirect(Account $account, string $priceId, string $cancelUrl):
]);
$subscription = $account->newSubscription(Account::SUBSCRIPTION_NAME, $priceId)
->quantity(max(1, $account->workspaces()->count()))
->allowPromotionCodes();
->quantity(max(1, $account->workspaces()->count()));
if ((bool) config('trypost.billing.require_card_for_trial', true)) {
$subscription->trialDays(config('cashier.trial_days'));
}
FirstMonthCheckoutDiscount::apply($subscription, $account);
$session = $subscription->checkout([
'success_url' => route('app.billing.processing').'?session_id={CHECKOUT_SESSION_ID}',

View file

@ -24,7 +24,7 @@ public static function execute(User $user, array $data): Workspace
'brand_color' => data_get($data, 'brand_color'),
'background_color' => data_get($data, 'background_color'),
'text_color' => data_get($data, 'text_color'),
'content_language' => data_get($data, 'content_language'),
'content_language' => data_get($data, 'content_language', app()->getLocale()),
], static fn ($value): bool => $value !== null);
$workspace = DB::transaction(function () use ($user, $attributes): Workspace {

View file

@ -5,6 +5,7 @@
namespace App\Ai\Agents;
use App\Enums\Workspace\BrandVoiceTrait;
use App\Enums\Workspace\ContentLanguage;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\HasStructuredOutput;
@ -20,6 +21,9 @@ public function instructions(): string
return view('prompts.brand_analyzer', [
'voice_groups' => BrandVoiceTrait::grouped(),
'single_select_groups' => BrandVoiceTrait::singleSelectGroups(),
'content_languages' => collect(ContentLanguage::values())
->map(fn (string $code) => "`{$code}`")
->implode(', '),
])->render();
}
@ -47,7 +51,7 @@ public function schema(JsonSchema $schema): array
->description('A concise 2-3 sentence brand description summarizing what the company does, who they serve, and what makes them unique. Written in the detected content language.')
->required(),
'language' => $schema->string()
->enum(['en', 'pt-BR', 'es'])
->enum(ContentLanguage::values())
->description('The primary language of the content.')
->required(),
'brand_color' => $schema->string()

View file

@ -4,17 +4,25 @@
namespace App\Console\Commands;
use App\Enums\SocialAccount\Platform;
use App\Enums\SocialAccount\Status;
use App\Jobs\RefreshSocialToken;
use App\Models\SocialAccount;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
class RefreshExpiringTokens extends Command
{
protected $signature = 'social:refresh-expiring-tokens';
protected $description = 'Proactively refresh tokens expiring in the next 2 hours (or already expired)';
protected $description = 'Proactively refresh social tokens before they expire';
/**
* Rotating refresh_token platforms only need a short lead: verify() won't
* rotate a still-valid token, so we catch them right before or after expiry.
* Extension-model platforms (Instagram/Threads) can't be refreshed once
* expired, so they get a much wider lead to survive queue backlog.
*/
public function handle(): void
{
$count = 0;
@ -22,7 +30,15 @@ public function handle(): void
SocialAccount::query()
->where('status', Status::Connected)
->whereNotNull('token_expires_at')
->where('token_expires_at', '<=', now()->addHours(2))
->where(function (Builder $query) {
$query->where(function (Builder $extension) {
$extension->whereIn('platform', Platform::accessTokenExtendingPlatformValues())
->where('token_expires_at', '<=', now()->addDay());
})->orWhere(function (Builder $rotating) {
$rotating->whereNotIn('platform', Platform::accessTokenExtendingPlatformValues())
->where('token_expires_at', '<=', now()->addMinutes(30));
});
})
->chunk(50, function ($accounts) use (&$count) {
foreach ($accounts as $account) {
RefreshSocialToken::dispatch($account);

View file

@ -6,6 +6,7 @@
use App\Enums\Media\Source;
use App\Enums\Media\Type;
use App\Enums\SocialAccount\Platform;
class MediaItem
{
@ -59,6 +60,39 @@ public function height(): ?int
return is_numeric($height) ? (int) $height : null;
}
/**
* User-provided accessibility description for this image, when set.
*/
public function altText(): ?string
{
$alt = data_get($this->meta, 'alt_text');
if (! is_string($alt)) {
return null;
}
$alt = trim($alt);
return $alt === '' ? null : $alt;
}
/**
* The alt text truncated to the given platform's cap, or null when no alt
* text is set or the platform doesn't support it. Single place that applies
* the per-platform cap so publishers don't each repeat the truncation.
*/
public function altTextFor(Platform $platform): ?string
{
$alt = $this->altText();
$max = $platform->altTextMaxLength();
if ($alt === null || $max === null) {
return null;
}
return mb_substr($alt, 0, $max);
}
/**
* @param array<string, mixed> $data
*/

View file

@ -127,6 +127,39 @@ public function maxImages(): int
};
}
/**
* Character cap the platform's API accepts for image alt text (accessibility
* description), or null when the platform has no alt-text field. X, LinkedIn,
* Instagram, Pinterest, and Discord use documented API maxes. Facebook,
* Threads, Mastodon, and Bluesky document no limit, so a defensive cap is
* used instead. Single source of truth — publishers truncate to this value,
* never a literal.
*/
public function altTextMaxLength(): ?int
{
return match ($this) {
self::Bluesky => 2000,
self::X => 1000,
self::Mastodon => 1500,
self::LinkedIn, self::LinkedInPage => 4086,
self::Facebook => 1000,
self::Instagram, self::InstagramFacebook => 1000,
self::Threads => 1000,
self::Pinterest => 500,
self::Discord => 1024,
self::TikTok, self::YouTube, self::Telegram => null,
};
}
/**
* Whether the platform's API accepts image alt text (accessibility
* description) on published media.
*/
public function supportsAltText(): bool
{
return $this->altTextMaxLength() !== null;
}
/**
* Hard cap (in characters) the platform's API will accept. Going over this
* means the post can't be published. Values are the documented API maxes:
@ -261,6 +294,60 @@ public function requiresContent(): bool
};
}
/**
* Whether this platform refreshes by extending the access_token itself
* (Instagram/Threads long-lived tokens) instead of exchanging a separate
* refresh_token. Extension-model tokens cannot be refreshed once expired,
* so they must be refreshed proactively while still valid — the opposite
* of rotating refresh_token platforms, which we avoid refreshing until
* they actually expire so we don't rotate a still-valid single-use token.
*/
public function extendsAccessTokenOnRefresh(): bool
{
return match ($this) {
self::Instagram, self::Threads => true,
default => false,
};
}
/**
* The `platform` column values of the platforms that refresh by extending
* their access token in place (Instagram and Threads — see
* extendsAccessTokenOnRefresh), for use in database whereIn/whereNotIn
* filters. Derived from extendsAccessTokenOnRefresh() so the two never drift.
*
* @return array<int, string>
*/
public static function accessTokenExtendingPlatformValues(): array
{
return array_values(array_map(
fn (self $platform): string => $platform->value,
array_filter(self::cases(), fn (self $platform): bool => $platform->extendsAccessTokenOnRefresh()),
));
}
/**
* The token lifetime, in seconds, to assume when the provider's OAuth
* response omits expires_in. Each value is that network's own documented
* default:
*
* - X: a 2-hour access token.
* - Instagram / Threads: Meta's 60-day long-lived token.
*
* Networks that always return expires_in (LinkedIn, TikTok, YouTube,
* Pinterest), whose refresh sets a fixed lifetime directly (Bluesky), or
* whose tokens never expire (Facebook, Mastodon, Telegram, Discord) have no
* fallback here and return null.
*/
public function defaultTokenTtlSeconds(): ?int
{
return match ($this) {
self::X => 7200,
self::Instagram, self::Threads => 5184000,
default => null,
};
}
public function queue(): string
{
return 'social-'.$this->value;

View file

@ -0,0 +1,146 @@
<?php
declare(strict_types=1);
namespace App\Enums\Workspace;
/**
* The set of languages the app supports, and the single source of truth for it:
* request validation, the brand analyzer's structured-output enum, homepage
* language detection, the AI image prompt's language name, the content-language
* picker options, and the right-to-left direction of the UI all derive from it.
*
* The string value is the language code stored on the workspace and passed
* straight to the content prompts (`content_language`); the same codes also back
* the application's UI locales, so `direction()` drives the document `dir` attribute.
*/
enum ContentLanguage: string
{
case English = 'en';
case PortugueseBrazil = 'pt-BR';
case Spanish = 'es';
case French = 'fr';
case German = 'de';
case Italian = 'it';
case Dutch = 'nl';
case Polish = 'pl';
case Greek = 'el';
case Japanese = 'ja';
case Korean = 'ko';
case Chinese = 'zh';
case Russian = 'ru';
case Turkish = 'tr';
case Arabic = 'ar';
public const DEFAULT = self::English;
/**
* The language's own name, shown in the content-language picker.
*/
public function label(): string
{
return match ($this) {
self::English => 'English',
self::PortugueseBrazil => 'Português (Brasil)',
self::Spanish => 'Español',
self::French => 'Français',
self::German => 'Deutsch',
self::Italian => 'Italiano',
self::Dutch => 'Nederlands',
self::Polish => 'Polski',
self::Greek => 'Ελληνικά',
self::Japanese => '日本語',
self::Korean => '한국어',
self::Chinese => '中文',
self::Russian => 'Русский',
self::Turkish => 'Türkçe',
self::Arabic => 'العربية',
};
}
/**
* The English name of the language, injected into AI image prompts so the
* in-image text is rendered in the workspace's content language.
*/
public function englishName(): string
{
return match ($this) {
self::English => 'English',
self::PortugueseBrazil => 'Brazilian Portuguese',
self::Spanish => 'Spanish',
self::French => 'French',
self::German => 'German',
self::Italian => 'Italian',
self::Dutch => 'Dutch',
self::Polish => 'Polish',
self::Greek => 'Greek',
self::Japanese => 'Japanese',
self::Korean => 'Korean',
self::Chinese => 'Chinese',
self::Russian => 'Russian',
self::Turkish => 'Turkish',
self::Arabic => 'Arabic',
};
}
/**
* The text direction (`ltr` or `rtl`) for the document root when this is the
* active UI locale — only Arabic is written right to left.
*/
public function direction(): string
{
return $this === self::Arabic ? 'rtl' : 'ltr';
}
/**
* Resolve a raw `<html lang>` value (e.g. "pt-PT", "zh-Hans") to a supported
* language by matching its primary subtag, or null if none is supported.
*/
public static function fromHtmlLang(string $lang): ?self
{
$subtag = self::primarySubtag($lang);
if (strlen($subtag) < 2) {
return null;
}
foreach (self::cases() as $language) {
if (self::primarySubtag($language->value) === $subtag) {
return $language;
}
}
return null;
}
/**
* The lowercased primary subtag of a BCP 47 language tag ("pt-BR" => "pt").
*/
private static function primarySubtag(string $tag): string
{
return strtolower(explode('-', trim($tag), 2)[0]);
}
/**
* @return array<int, string>
*/
public static function values(): array
{
return array_map(fn (self $language) => $language->value, self::cases());
}
/**
* @return array<int, array{value: string, label: string, englishName: string}>
*/
public static function options(): array
{
return array_map(
fn (self $language) => [
'value' => $language->value,
'label' => $language->label(),
'englishName' => $language->englishName(),
],
self::cases(),
);
}
}

View file

@ -150,9 +150,15 @@ public function connect(Request $request): Response|RedirectResponse
'network' => $platform->network(),
])->values();
$plan = Plan::where('slug', Slug::Workspace)->firstOrFail();
return Inertia::render('onboarding/Connect', [
'platforms' => $platforms,
'accounts' => SocialAccountResource::collection($accounts)->resolve(),
'plan' => [
'name' => $plan->name,
'interval' => 'monthly',
],
]);
}

View file

@ -9,23 +9,23 @@
use App\Actions\Workspace\DeleteWorkspace;
use App\Enums\Workspace\BrandFont;
use App\Enums\Workspace\BrandVoiceTrait;
use App\Enums\Workspace\ContentLanguage;
use App\Enums\Workspace\ImageStyle;
use App\Http\Requests\App\Workspace\AutofillBrandRequest;
use App\Http\Requests\App\Workspace\StoreWorkspaceRequest;
use App\Http\Requests\App\Workspace\UpdateWorkspaceRequest;
use App\Http\Resources\App\WorkspaceMemberResource;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Brand\LogoAttacher;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Support\Facades\Log;
use Inertia\Inertia;
use Inertia\Response;
use RuntimeException;
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
use Throwable;
class WorkspaceController extends Controller
{
@ -67,8 +67,26 @@ public function index(Request $request): Response
public function create(Request $request): Response|RedirectResponse
{
$user = $request->user();
if ($redirect = $this->denyAdditionalWorkspaceWithoutSubscription($request->user())) {
return $redirect;
}
return Inertia::render('workspaces/Create', [
'availableFonts' => BrandFont::values(),
'availableImageStyles' => ImageStyle::values(),
'availableVoiceTraits' => BrandVoiceTrait::grouped(),
'availableContentLanguages' => ContentLanguage::options(),
]);
}
/**
* Block creating a paid additional workspace without an active subscription.
* Guards both the form (`create`) and the write (`store`) so a direct POST
* can't bootstrap a second billable workspace — which would also inflate the
* checkout quantity past the fixed first-month coupon.
*/
private function denyAdditionalWorkspaceWithoutSubscription(User $user): ?RedirectResponse
{
if (! config('trypost.self_hosted')
&& $user->ownedWorkspacesCount() > 0
&& ! $user->account?->hasActiveSubscription()) {
@ -76,11 +94,7 @@ public function create(Request $request): Response|RedirectResponse
->with('message', 'Subscribe to create more workspaces.');
}
return Inertia::render('workspaces/Create', [
'availableFonts' => BrandFont::values(),
'availableImageStyles' => ImageStyle::values(),
'availableVoiceTraits' => BrandVoiceTrait::grouped(),
]);
return null;
}
public function autofillBrand(AutofillBrandRequest $request, AutofillBrand $autofill): JsonResponse
@ -98,20 +112,16 @@ public function store(StoreWorkspaceRequest $request, LogoAttacher $logoAttacher
{
$user = $request->user();
if ($redirect = $this->denyAdditionalWorkspaceWithoutSubscription($user)) {
return $redirect;
}
$validated = $request->validated();
$workspace = CreateWorkspace::execute($user, $validated);
if ($logoUrl = data_get($validated, 'logo_url')) {
try {
$logoAttacher->attach($workspace, $logoUrl);
} catch (Throwable $e) {
Log::warning('Logo attach failed during workspace creation', [
'workspace_id' => $workspace->id,
'logo_url' => $logoUrl,
'error' => $e->getMessage(),
]);
}
$logoAttacher->attach($workspace, $logoUrl);
}
return redirect()->route('app.accounts')
@ -163,6 +173,7 @@ public function brandSettings(Request $request): Response|RedirectResponse
'availableFonts' => BrandFont::values(),
'availableImageStyles' => ImageStyle::values(),
'availableVoiceTraits' => BrandVoiceTrait::grouped(),
'availableContentLanguages' => ContentLanguage::options(),
]);
}
@ -195,7 +206,7 @@ public function deleteLogo(Request $request): RedirectResponse
return back()->with('flash.success', __('settings.flash.logo_deleted'));
}
public function updateSettings(UpdateWorkspaceRequest $request): RedirectResponse
public function updateSettings(UpdateWorkspaceRequest $request, LogoAttacher $logoAttacher): RedirectResponse
{
$user = $request->user();
$workspace = $user->currentWorkspace;
@ -206,7 +217,16 @@ public function updateSettings(UpdateWorkspaceRequest $request): RedirectRespons
$this->authorize('update', $workspace);
$workspace->update($request->validated());
$validated = $request->validated();
$logoUrl = data_get($validated, 'logo_url');
unset($validated['logo_url']);
$workspace->update($validated);
if ($logoUrl) {
$logoAttacher->attach($workspace, $logoUrl);
}
return back()->with('flash.success', __('settings.flash.workspace_updated'));
}

View file

@ -69,7 +69,7 @@ public function callback(Request $request): InertiaResponse
$avatarPath = $socialUser->getAvatar() ? uploadFromUrl($socialUser->getAvatar()) : null;
// Calculate token expiration (long-lived tokens last 60 days)
$expiresIn = $socialUser->expiresIn ?? 5184000; // 60 days in seconds
$expiresIn = $socialUser->expiresIn ?? $this->platform->defaultTokenTtlSeconds();
$tokenExpiresAt = now()->addSeconds($expiresIn);
$workspace->socialAccounts()->updateOrCreate(

View file

@ -8,6 +8,7 @@
use App\Enums\SocialAccount\Status;
use App\Exceptions\SocialAccount\NetworkAlreadyConnectedException;
use App\Models\Workspace;
use App\Services\Social\TokenRedactor;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
@ -90,7 +91,7 @@ public function callback(Request $request): InertiaResponse
if ($tokenResponse->failed()) {
Log::error('Threads token exchange failed', [
'status' => $tokenResponse->status(),
'body' => $tokenResponse->body(),
'body' => TokenRedactor::redact($tokenResponse->body()),
]);
throw new \Exception('Failed to exchange token');
}
@ -106,15 +107,18 @@ public function callback(Request $request): InertiaResponse
'access_token' => $shortLivedToken,
]);
$longLivedToken = $shortLivedToken;
$expiresIn = null;
if ($longLivedResponse->successful()) {
$longLivedData = $longLivedResponse->json();
$longLivedToken = $longLivedData['access_token'] ?? $shortLivedToken;
$expiresIn = $longLivedData['expires_in'] ?? null;
if ($longLivedResponse->failed()) {
Log::error('Threads long-lived token exchange failed', [
'status' => $longLivedResponse->status(),
'body' => TokenRedactor::redact($longLivedResponse->body()),
]);
throw new \Exception('Failed to exchange long-lived token');
}
$longLivedData = $longLivedResponse->json();
$longLivedToken = $longLivedData['access_token'] ?? $shortLivedToken;
$expiresIn = $longLivedData['expires_in'] ?? $this->platform->defaultTokenTtlSeconds();
// Fetch user profile
$profileResponse = Http::get(config('trypost.platforms.threads.graph_api')."/{$userId}", [
'access_token' => $longLivedToken,
@ -142,7 +146,7 @@ public function callback(Request $request): InertiaResponse
'avatar_url' => $avatarPath,
'access_token' => $longLivedToken,
'refresh_token' => null,
'token_expires_at' => $expiresIn ? now()->addSeconds($expiresIn) : null,
'token_expires_at' => now()->addSeconds($expiresIn),
'scopes' => $this->scopes,
'status' => Status::Connected,
'error_message' => null,

View file

@ -4,9 +4,11 @@
namespace App\Http\Middleware\App;
use App\Enums\Workspace\ContentLanguage;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\View;
use Symfony\Component\HttpFoundation\Response;
class SetLocale
@ -19,8 +21,11 @@ public function handle(Request $request, Closure $next): Response
$available = config('languages.available');
$locale = $request->cookie('locale');
$isValid = $locale && array_key_exists($locale, $available);
$activeLocale = $isValid ? $locale : config('languages.default');
$language = ContentLanguage::tryFrom($activeLocale) ?? ContentLanguage::DEFAULT;
App::setLocale($isValid ? $locale : config('languages.default'));
App::setLocale($activeLocale);
View::share('htmlDir', $language->direction());
$response = $next($request);

View file

@ -4,6 +4,7 @@
namespace App\Http\Requests\Api\Post;
use App\Support\PostMediaRules;
use Illuminate\Foundation\Http\FormRequest;
class AttachMediaFromUrlRequest extends FormRequest
@ -20,7 +21,8 @@ public function rules(): array
{
return [
'urls' => ['required', 'array', 'min:1', 'max:10'],
'urls.*' => ['url:http,https', 'active_url'],
'urls.*.url' => ['required', 'url:http,https', 'active_url'],
'urls.*.alt' => ['nullable', 'string', 'max:'.PostMediaRules::ALT_TEXT_MAX_LENGTH],
];
}
}

View file

@ -6,6 +6,7 @@
use App\Enums\Workspace\BrandFont;
use App\Enums\Workspace\BrandVoiceTrait;
use App\Enums\Workspace\ContentLanguage;
use App\Enums\Workspace\ImageStyle;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
@ -32,7 +33,7 @@ public function rules(): array
'text_color' => $hex,
'brand_font' => ['sometimes', 'string', Rule::in(BrandFont::values())],
'image_style' => ['sometimes', 'string', Rule::in(ImageStyle::values())],
'content_language' => ['nullable', 'string', 'in:en,pt-BR,es'],
'content_language' => ['nullable', 'string', Rule::in(ContentLanguage::values())],
'logo_url' => ['nullable', 'url', 'max:1024'],
];
}

View file

@ -6,6 +6,7 @@
use App\Enums\Workspace\BrandFont;
use App\Enums\Workspace\BrandVoiceTrait;
use App\Enums\Workspace\ContentLanguage;
use App\Enums\Workspace\ImageStyle;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
@ -32,7 +33,8 @@ public function rules(): array
'text_color' => $hex,
'brand_font' => ['sometimes', 'required', 'string', Rule::in(BrandFont::values())],
'image_style' => ['sometimes', 'required', 'string', Rule::in(ImageStyle::values())],
'content_language' => ['sometimes', 'string', 'in:en,pt-BR,es'],
'content_language' => ['sometimes', 'string', Rule::in(ContentLanguage::values())],
'logo_url' => ['nullable', 'url', 'max:1024'],
];
}

View file

@ -282,6 +282,10 @@ private function replaceMediaOnPost(
throw new RuntimeException('Media item changed before regeneration completed.');
}
if (($meta = data_get($items->get($currentIndex), 'meta')) !== null) {
$newMediaItem['meta'] = $meta;
}
$items->put($currentIndex, $newMediaItem);
$fresh->update(['media' => $items->values()->all()]);

View file

@ -24,7 +24,14 @@ public function __construct(public SocialAccount $account) {}
public function handle(ConnectionVerifier $verifier): void
{
try {
$verifier->refreshToken($this->account);
if ($this->account->platform->extendsAccessTokenOnRefresh()) {
// Instagram/Threads extend the long-lived token itself and
// can't be refreshed once expired, so extend it while it's
// still valid instead of waiting for it to fail.
$verifier->refreshToken($this->account);
} else {
$verifier->verify($this->account);
}
} catch (PlatformUnavailableException $e) {
Log::warning('Token refresh skipped: platform unavailable', [
'account_id' => $this->account->id,

View file

@ -8,6 +8,7 @@
use App\Models\Media;
use App\Models\Post;
use App\Models\Workspace;
use App\Support\PostMediaRules;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
@ -23,6 +24,7 @@ public function handle(Request $request): Response|ResponseFactory
$validated = $request->validate([
'post_id' => ['required', 'uuid'],
'upload_token' => ['required', 'uuid'],
'alt' => ['nullable', 'string', 'max:'.PostMediaRules::ALT_TEXT_MAX_LENGTH],
]);
$workspaceId = $request->user()->current_workspace_id;
@ -48,14 +50,20 @@ public function handle(Request $request): Response|ResponseFactory
return Response::error('No enabled platform on this post accepts this media type.');
}
$post->appendMedia([[
$item = [
'id' => $media->id,
'path' => $media->path,
'url' => $media->url,
'type' => $media->type,
'mime_type' => $media->mime_type,
'original_filename' => $media->original_filename,
]]);
];
if (($alt = data_get($validated, 'alt')) !== null && $media->isImage()) {
$item['meta'] = ['alt_text' => $alt];
}
$post->appendMedia([$item]);
$post->refresh()->load(['postPlatforms.socialAccount', 'labels']);
@ -69,6 +77,7 @@ public function schema(JsonSchema $schema): array
return [
'post_id' => $schema->string()->required()->description('UUID of the post to attach the uploaded media to.'),
'upload_token' => $schema->string()->required()->description('upload_token returned by RequestMediaUploadTool, after the user has POSTed the file to the upload_url.'),
'alt' => $schema->string()->description('Optional accessibility alt text for the media (applies to images).'),
];
}
}

View file

@ -7,6 +7,7 @@
use App\Http\Resources\Api\PostResource;
use App\Models\Post;
use App\Services\Post\MediaAttacher;
use App\Support\PostMediaRules;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
@ -22,7 +23,8 @@ public function handle(Request $request): Response|ResponseFactory
$validated = $request->validate([
'post_id' => ['required', 'uuid'],
'urls' => ['required', 'array', 'min:1', 'max:10'],
'urls.*' => ['url:http,https', 'active_url'],
'urls.*.url' => ['required', 'url:http,https', 'active_url'],
'urls.*.alt' => ['nullable', 'string', 'max:'.PostMediaRules::ALT_TEXT_MAX_LENGTH],
]);
$post = Post::where('workspace_id', $request->user()->current_workspace_id)
@ -51,9 +53,12 @@ public function schema(JsonSchema $schema): array
return [
'post_id' => $schema->string()->required()->description('UUID of the post to attach media to.'),
'urls' => $schema->array()
->items($schema->string())
->items($schema->object(fn ($u) => [
'url' => $u->string()->required()->description('Public HTTP/HTTPS URL of an image, video, or PDF.'),
'alt' => $u->string()->description('Optional accessibility alt text for the image (ignored for video/PDF, which have no alt text).'),
]))
->required()
->description('Public HTTP/HTTPS URLs of images, videos, or PDFs. Max 10 URLs per call, 50MB per file. Allowed types: image/jpeg, image/png, image/gif, image/webp, video/mp4, video/quicktime, application/pdf.'),
->description('Media to attach. Max 10 per call, 50MB per file. Allowed types: image/jpeg, image/png, image/gif, image/webp, video/mp4, video/quicktime, application/pdf.'),
];
}
}

View file

@ -93,6 +93,19 @@ protected function isTokenExpiringSoon(): Attribute
);
}
/**
* Whether the token should be refreshed before use. Rotating-refresh-token
* platforms are only refreshed once actually expired, to avoid rotating a
* still-valid single-use refresh_token; extension-model platforms
* (Instagram/Threads) must be refreshed while still valid because their
* token can't be extended once expired.
*/
public function needsProactiveTokenRefresh(): bool
{
return $this->is_token_expired
|| ($this->platform->extendsAccessTokenOnRefresh() && $this->is_token_expiring_soon);
}
protected function avatarUrl(): Attribute
{
return Attribute::make(

View file

@ -4,6 +4,7 @@
namespace App\Services\Ai;
use App\Enums\Workspace\ContentLanguage;
use App\Enums\Workspace\ImageStyle;
use App\Support\HexColorName;
use Illuminate\Support\Facades\Log;
@ -89,11 +90,7 @@ public function generate(
private function languageName(string $code): string
{
return match ($code) {
'pt-BR' => 'Brazilian Portuguese',
'es' => 'Spanish',
default => 'English',
};
return (ContentLanguage::tryFrom($code) ?? ContentLanguage::DEFAULT)->englishName();
}
/**

View file

@ -4,6 +4,7 @@
namespace App\Services\Brand;
use App\Enums\Workspace\ContentLanguage;
use Symfony\Component\DomCrawler\Crawler;
use Symfony\Component\DomCrawler\UriResolver;
@ -291,14 +292,7 @@ private function extractLanguage(Crawler $crawler): ?string
return null;
}
$lower = strtolower($lang);
return match (true) {
str_starts_with($lower, 'pt') => 'pt-BR',
str_starts_with($lower, 'es') => 'es',
str_starts_with($lower, 'en') => 'en',
default => null,
};
return ContentLanguage::fromHtmlLang($lang)?->value;
}
private function extractLogoUrl(Crawler $crawler, string $baseUrl): ?string

View file

@ -6,6 +6,7 @@
use App\Models\Workspace;
use Illuminate\Support\Facades\Log;
use Throwable;
class LogoAttacher
{
@ -77,6 +78,10 @@ public function attach(Workspace $workspace, string $logoUrl): bool
);
return true;
} catch (Throwable $e) {
Log::warning('Logo rejected — persistence error', ['url' => $logoUrl, 'error' => $e->getMessage()]);
return false;
} finally {
if (file_exists($tempPath)) {
@unlink($tempPath);

View file

@ -15,7 +15,7 @@ class GiphyService
public function __construct()
{
$this->apiKey = config('services.giphy.api_key', '');
$this->apiKey = (string) config('services.giphy.api_key', '');
}
/**

View file

@ -6,11 +6,29 @@
use App\Enums\SocialAccount\Platform;
use Illuminate\Support\Facades\Log;
use Intervention\Image\Direction;
use Intervention\Image\Drivers\Gd\Driver;
use Intervention\Image\Drivers\Imagick\Driver as ImagickDriver;
use Intervention\Image\ImageManager;
use Intervention\Image\Interfaces\ImageInterface;
use RuntimeException;
class MediaOptimizer
{
private const MAX_DECODE_MEMORY_BYTES = 256 * 1024 * 1024;
private const FIT_BLUR_SIGMA = 55;
private const FIT_BLUR_GAMMA = 1.3;
private const FIT_QUALITY = 90;
private const FIT_GD_DOWNSCALE = 8;
private const FIT_GD_BLUR = 45;
private const FIT_GD_BRIGHTNESS = 12;
private ImageManager $manager;
public function __construct()
@ -26,29 +44,18 @@ public function optimizeImage(string $filePath, Platform $platform): string
{
$config = $this->getImageConfig($platform);
// Check image dimensions to prevent GD memory overflow
$imageInfo = @getimagesize($filePath);
if ($imageInfo) {
$width = $imageInfo[0];
$height = $imageInfo[1];
$channels = $imageInfo['channels'] ?? 4;
$estimatedMemory = $width * $height * $channels * 1.5; // 1.5x safety margin
if ($imageInfo !== false && $this->estimatedDecodeMemory($imageInfo) > self::MAX_DECODE_MEMORY_BYTES) {
Log::warning('MediaOptimizer: Image too large for GD processing', [
'width' => $imageInfo[0],
'height' => $imageInfo[1],
'platform' => $platform->value,
]);
// If image would use more than 256MB of RAM, skip optimization and return as-is
if ($estimatedMemory > 256 * 1024 * 1024) {
Log::warning('MediaOptimizer: Image too large for GD processing', [
'width' => $width,
'height' => $height,
'estimated_memory' => $estimatedMemory,
'platform' => $platform->value,
]);
$tempFile = tempnam(sys_get_temp_dir(), 'media_opt_');
copy($filePath, $tempFile);
// Copy original file to temp location and return
$tempFile = tempnam(sys_get_temp_dir(), 'media_opt_');
copy($filePath, $tempFile);
return $tempFile;
}
return $tempFile;
}
$image = $this->manager->decodePath($filePath);
@ -113,6 +120,8 @@ public function maxWidthForPlatform(Platform $platform): ?int
*/
public function cropToAspectRatio(string $filePath, float $ratio): string
{
$this->assertWithinMemoryBudget($filePath);
$image = $this->manager->decodePath($filePath);
$width = $image->width();
@ -148,6 +157,131 @@ public function cropToAspectRatio(string $filePath, float $ratio): string
return $tempFile;
}
/**
* Fit an image inside a width×height canvas without cropping: the image is
* scaled to fit and centered, and the empty space is filled with a blurred,
* slightly darkened copy of the image. When the image already matches the
* canvas ratio it's just scaled down (no background). Returns a temp file.
*/
public function fitToCanvas(string $filePath, int $width, int $height): string
{
$this->assertWithinMemoryBudget($filePath);
$foreground = $this->manager->decodePath($filePath);
$canvasRatio = $width / $height;
$imageRatio = $foreground->width() / $foreground->height();
$tempFile = tempnam(sys_get_temp_dir(), 'media_fit_');
try {
if (abs($imageRatio - $canvasRatio) < 0.01) {
$sized = $foreground->scaleDown($width, $height);
file_put_contents($tempFile, (string) $sized->encodeUsingMediaType('image/jpeg', quality: self::FIT_QUALITY));
return $tempFile;
}
$canvas = extension_loaded('imagick')
? $this->fitOntoBlurredBackground($filePath, $width, $height)
: $this->fitOntoBlurredBackgroundGd($filePath, $width, $height);
file_put_contents($tempFile, (string) $canvas->encodeUsingMediaType('image/jpeg', quality: self::FIT_QUALITY));
return $tempFile;
} catch (\Throwable $e) {
@unlink($tempFile);
throw $e;
}
}
/**
* Fit the image onto the canvas over a soft, lightened blurred background
* built from the image itself: the image is scaled to fill the width, heavily
* gaussian-blurred so shapes dissolve into a colour wash, lightened, and the
* top half is mirrored onto the bottom so the background reads symmetrically.
* Imagick only — blurring at full width before stretching avoids the streaks
* and posterisation the GD path is prone to.
*/
private function fitOntoBlurredBackground(string $filePath, int $width, int $height): ImageInterface
{
$manager = new ImageManager(new ImagickDriver);
$foreground = $manager->decodePath($filePath);
$sourceWidth = $foreground->width();
$sourceHeight = $foreground->height();
$scale = min($width / $sourceWidth, $height / $sourceHeight);
$foreground->resize(max(1, (int) round($sourceWidth * $scale)), max(1, (int) round($sourceHeight * $scale)));
$scaledHeight = max(1, (int) round($sourceHeight * ($width / $sourceWidth)));
$topHalf = $manager->decodePath($filePath)->resize($width, $scaledHeight);
$core = $topHalf->core()->native();
$core->blurImage(0, self::FIT_BLUR_SIGMA);
$core->gammaImage(self::FIT_BLUR_GAMMA);
$topHalf->resize($width, intdiv($height, 2));
$canvas = $manager->createImage($width, $height)->fill('000000');
$canvas->insert($topHalf, 0, 0, 'top-left');
$topHalf->flip(Direction::VERTICAL);
$canvas->insert($topHalf, 0, intdiv($height, 2), 'top-left');
$canvas->insert($foreground, 0, 0, 'center');
return $canvas;
}
/**
* GD fallback for hosts without ext-imagick: a downscale→blur→upscale
* background. Less refined than the Imagick path (no large smooth gaussian),
* but artefact-free enough for a story background.
*/
private function fitOntoBlurredBackgroundGd(string $filePath, int $width, int $height): ImageInterface
{
$foreground = $this->manager->decodePath($filePath);
$scale = min($width / $foreground->width(), $height / $foreground->height());
$foreground->resize(max(1, (int) round($foreground->width() * $scale)), max(1, (int) round($foreground->height() * $scale)));
$canvas = $this->manager->decodePath($filePath)
->cover($width, $height)
->resize(intdiv($width, self::FIT_GD_DOWNSCALE), intdiv($height, self::FIT_GD_DOWNSCALE))
->blur(self::FIT_GD_BLUR)
->resize($width, $height)
->brightness(self::FIT_GD_BRIGHTNESS);
$canvas->insert($foreground, 0, 0, 'center');
return $canvas;
}
/**
* Estimated GD memory (bytes) needed to decode an image, from its
* getimagesize() metadata.
*
* @param array{0: int, 1: int, channels?: int} $imageInfo
*/
private function estimatedDecodeMemory(array $imageInfo): float
{
return $imageInfo[0] * $imageInfo[1] * ($imageInfo['channels'] ?? 4) * 1.5;
}
/**
* Reject a source whose pixel dimensions would blow the GD memory budget,
* before it is decoded — a small-byte, huge-dimension image would otherwise
* exhaust memory with an uncatchable fatal. Transforms that can't fall back
* to the original (crop, fit) call this; `optimizeImage` skips instead.
*/
private function assertWithinMemoryBudget(string $filePath): void
{
$imageInfo = @getimagesize($filePath);
if ($imageInfo === false) {
return;
}
if ($this->estimatedDecodeMemory($imageInfo) > self::MAX_DECODE_MEMORY_BYTES) {
throw new RuntimeException("Image dimensions ({$imageInfo[0]}x{$imageInfo[1]}) exceed the safe processing budget.");
}
}
/**
* @return array{max_width: int, max_size: int, format: string, quality: int}
*/

View file

@ -25,7 +25,7 @@
class MediaAttacher
{
/**
* @param array<int, string> $urls
* @param array<int, array{url: string, alt?: ?string}> $urls
* @return array{attached: array<int, array<string, mixed>>, failed: array<int, string>}
*/
public function attachFromUrls(Post $post, array $urls): array
@ -33,10 +33,22 @@ public function attachFromUrls(Post $post, array $urls): array
$attached = [];
$failed = [];
foreach ($urls as $url) {
($item = $this->fetchToWorkspace($post->workspace, $post->allowedMediaTypes(), $url)) === null
? $failed[] = $url
: $attached[] = $item;
foreach ($urls as $entry) {
$url = (string) data_get($entry, 'url', '');
$item = $this->fetchToWorkspace($post->workspace, $post->allowedMediaTypes(), $url);
if ($item === null) {
$failed[] = $url;
continue;
}
if (($alt = data_get($entry, 'alt')) !== null
&& MediaType::classify(data_get($item, 'mime_type'), data_get($item, 'path')) === MediaType::Image) {
$item['meta'] = ['alt_text' => $alt];
}
$attached[] = $item;
}
if ($attached !== []) {
@ -77,6 +89,10 @@ public function resolveInlineMedia(Workspace $workspace, array $allowedTypes, ar
continue;
}
if (($meta = data_get($item, 'meta')) !== null) {
$hosted['meta'] = $meta;
}
$media[] = $hosted;
$hostedIds[] = data_get($hosted, 'id');
}

View file

@ -66,7 +66,7 @@ public function publish(PostPlatform $postPlatform): array
$this->account = $postPlatform->socialAccount;
$this->hasRetried = false;
if ($this->account->is_token_expired || $this->account->is_token_expiring_soon) {
if ($this->account->needsProactiveTokenRefresh()) {
app(ConnectionVerifier::class)->refreshToken($this->account);
}
@ -121,10 +121,14 @@ private function publishPost(?string $content, $media): array
$payload = $this->basePayload($content);
if ($media->isNotEmpty()) {
$mediaUrn = $this->uploadMedia($media->first());
$item = $media->first();
$mediaUrn = $this->uploadMedia($item);
if ($mediaUrn) {
$payload['content'] = ['media' => ['id' => $mediaUrn]];
$payload['content'] = ['media' => array_filter([
'id' => $mediaUrn,
'altText' => $item->isImage() ? $item->altTextFor($this->platform()) : null,
], fn ($v) => $v !== null)];
}
}
@ -143,10 +147,10 @@ private function publishCarousel(?string $content, $media): array
$imageUrn = $this->uploadImage($item);
if ($imageUrn) {
$images[] = [
$images[] = array_filter([
'id' => $imageUrn,
'altText' => $item->original_filename ?? 'Carousel image',
];
'altText' => $item->altTextFor($this->platform()),
], fn ($v) => $v !== null);
}
}

View file

@ -48,7 +48,7 @@ public function publish(PostPlatform $postPlatform): array
$service = $account->meta['service'] ?? config('trypost.platforms.bluesky.default_service');
// Refresh token if needed
if ($account->is_token_expired || $account->is_token_expiring_soon) {
if ($account->needsProactiveTokenRefresh()) {
app(ConnectionVerifier::class)->refreshToken($account);
}
@ -63,7 +63,7 @@ public function publish(PostPlatform $postPlatform): array
$blob = $this->uploadBlob($account, $service, $media->url, $media->mime_type);
if ($blob) {
$images[] = [
'alt' => '',
'alt' => $media->altTextFor(Platform::Bluesky) ?? '',
'image' => $blob,
];
}

View file

@ -37,14 +37,55 @@ protected function cropImageForAspectRatio(string $imageUrl, ?string $aspectRati
throw $this->cropFailureException('Failed to download image for cropping');
}
$cropped = app(MediaOptimizer::class)->cropToAspectRatio($tempInput, $ratio);
try {
$cropped = app(MediaOptimizer::class)->cropToAspectRatio($tempInput, $ratio);
} catch (\Throwable) {
throw $this->cropFailureException('Failed to process image for cropping');
}
$path = self::CROP_DIRECTORY.'/'.Str::uuid()->toString().'.jpg';
Storage::put($path, file_get_contents($cropped));
try {
$path = self::CROP_DIRECTORY.'/'.Str::uuid()->toString().'.jpg';
Storage::put($path, file_get_contents($cropped));
@unlink($cropped);
return Storage::url($path);
} finally {
@unlink($cropped);
}
} finally {
@unlink($tempInput);
}
}
return Storage::url($path);
/**
* Fit the image inside a width×height canvas with a blurred-background
* extension (no cropping), host it, and return a public URL. Used for
* stories so an off-ratio image isn't clipped by the platform.
*/
protected function fitImageToCanvas(string $imageUrl, int $width, int $height): string
{
$tempInput = tempnam(sys_get_temp_dir(), 'fit_in_');
try {
$download = Http::sink($tempInput)->timeout(120)->get($imageUrl);
if ($download->failed()) {
throw $this->cropFailureException('Failed to download image for story fitting');
}
try {
$fitted = app(MediaOptimizer::class)->fitToCanvas($tempInput, $width, $height);
} catch (\Throwable) {
throw $this->cropFailureException('Failed to process image for story fitting');
}
try {
$path = self::CROP_DIRECTORY.'/'.Str::uuid()->toString().'.jpg';
Storage::put($path, file_get_contents($fitted));
return Storage::url($path);
} finally {
@unlink($fitted);
}
} finally {
@unlink($tempInput);
}
@ -56,8 +97,8 @@ protected function aspectRatioToFloat(string $ratio): float
}
/**
* The platform-specific exception thrown when the source image cannot be
* downloaded for cropping.
* The platform-specific exception thrown when an image can't be prepared for
* publishing — a download, crop, or story-fit failure.
*/
abstract protected function cropFailureException(string $message): SocialPublishException;
}

View file

@ -9,6 +9,7 @@
use App\Exceptions\TokenExpiredException;
use App\Models\SocialAccount;
use App\Services\Social\Discord\DiscordClient;
use App\Services\Social\Meta\GraphError;
use App\Services\Social\Telegram\TelegramApi;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
@ -31,9 +32,7 @@ public function verify(SocialAccount $account): bool
// refresh, so proactive refreshes during races cause false-positive
// disconnects even though the access_token still works fine.
if ($account->is_token_expired) {
$this->refreshToken($account);
return $this->callVerifyEndpoint($account);
return $this->refreshThenVerify($account);
}
try {
@ -41,13 +40,40 @@ public function verify(SocialAccount $account): bool
} catch (TokenExpiredException $e) {
// Verify returned 401: the access_token is actually invalid.
// Refresh and retry once with the new token.
try {
$this->refreshToken($account);
} catch (TokenExpiredException) {
throw $e;
}
return $this->refreshThenVerify($account, $e);
}
}
/**
* Refresh the token, then verify with the new one.
*
* If either the refresh is rejected (4xx) or the verify that follows it hits
* a token a concurrent refresh has already rotated — including the sub-commit
* window where a lock-skipped refresh reloads a not-yet-persisted token —
* reload and, when another process has since persisted a fresh access_token,
* verify with that instead of giving up. X (and other providers that
* single-use their refresh_token) would otherwise disconnect a still-usable
* account whenever two refreshes race and one loses.
*
* @throws TokenExpiredException
* @throws PlatformUnavailableException
*/
private function refreshThenVerify(SocialAccount $account, ?TokenExpiredException $original = null): bool
{
$accessTokenBeforeRefresh = $account->access_token;
try {
$this->refreshToken($account);
return $this->callVerifyEndpoint($account);
} catch (TokenExpiredException $e) {
$account->refresh();
if ($account->access_token !== $accessTokenBeforeRefresh) {
return $this->callVerifyEndpoint($account);
}
throw $original ?? $e;
}
}
@ -155,7 +181,7 @@ private function refreshXToken(SocialAccount $account): void
$account->update([
'access_token' => data_get($data, 'access_token'),
'refresh_token' => data_get($data, 'refresh_token', $account->refresh_token),
'token_expires_at' => now()->addSeconds(data_get($data, 'expires_in', 7200)),
'token_expires_at' => now()->addSeconds(data_get($data, 'expires_in', $account->platform->defaultTokenTtlSeconds())),
]);
$account->refresh();
@ -288,10 +314,13 @@ private function refreshPinterestToken(SocialAccount $account): void
private function refreshThreadsToken(SocialAccount $account): void
{
// Threads uses long-lived tokens that can be refreshed
$response = TokenRefreshClient::for(Platform::Threads)->send(fn () => Http::get(config('trypost.platforms.threads.auth_api').'/refresh_access_token', [
'grant_type' => 'th_refresh_token',
'access_token' => $account->access_token,
]));
$response = TokenRefreshClient::for(Platform::Threads)->send(
fn () => Http::get(config('trypost.platforms.threads.auth_api').'/refresh_access_token', [
'grant_type' => 'th_refresh_token',
'access_token' => $account->access_token,
]),
GraphError::indicatesInvalidToken(...),
);
$data = $response->json();
$newToken = data_get($data, 'access_token');
@ -299,7 +328,7 @@ private function refreshThreadsToken(SocialAccount $account): void
$account->update([
'access_token' => $newToken,
'refresh_token' => $newToken,
'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null,
'token_expires_at' => now()->addSeconds(data_get($data, 'expires_in', $account->platform->defaultTokenTtlSeconds())),
]);
$account->refresh();
@ -307,10 +336,13 @@ private function refreshThreadsToken(SocialAccount $account): void
private function refreshInstagramToken(SocialAccount $account): void
{
$response = TokenRefreshClient::for(Platform::Instagram)->send(fn () => Http::get(config('trypost.platforms.instagram.auth_api').'/refresh_access_token', [
'grant_type' => 'ig_refresh_token',
'access_token' => $account->access_token,
]));
$response = TokenRefreshClient::for(Platform::Instagram)->send(
fn () => Http::get(config('trypost.platforms.instagram.auth_api').'/refresh_access_token', [
'grant_type' => 'ig_refresh_token',
'access_token' => $account->access_token,
]),
GraphError::indicatesInvalidToken(...),
);
$data = $response->json();
$newToken = data_get($data, 'access_token');
@ -318,7 +350,7 @@ private function refreshInstagramToken(SocialAccount $account): void
$account->update([
'access_token' => $newToken,
'refresh_token' => $newToken,
'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null,
'token_expires_at' => now()->addSeconds(data_get($data, 'expires_in', $account->platform->defaultTokenTtlSeconds())),
]);
$account->refresh();
@ -385,13 +417,8 @@ private function verifyInstagram(SocialAccount $account): bool
$body = $response->json() ?? [];
if (isset($body['error'])) {
$errorCode = $body['error']['code'] ?? null;
$errorType = $body['error']['type'] ?? null;
if ($errorType === 'OAuthException' || $errorCode === 190) {
throw new TokenExpiredException('Instagram access token is invalid or expired');
}
if (GraphError::indicatesInvalidToken($body)) {
throw new TokenExpiredException('Instagram access token is invalid or expired');
}
return $response->successful();
@ -406,13 +433,8 @@ private function verifyFacebook(SocialAccount $account): bool
$body = $response->json() ?? [];
if (isset($body['error'])) {
$errorCode = $body['error']['code'] ?? null;
$errorType = $body['error']['type'] ?? null;
if ($errorType === 'OAuthException' || $errorCode === 190) {
throw new TokenExpiredException('Facebook access token is invalid or expired');
}
if (GraphError::indicatesInvalidToken($body)) {
throw new TokenExpiredException('Facebook access token is invalid or expired');
}
return $response->successful();
@ -427,13 +449,8 @@ private function verifyThreads(SocialAccount $account): bool
$body = $response->json() ?? [];
if (isset($body['error'])) {
$errorCode = $body['error']['code'] ?? null;
$errorType = $body['error']['type'] ?? null;
if ($errorType === 'OAuthException' || $errorCode === 190) {
throw new TokenExpiredException('Threads access token is invalid or expired');
}
if (GraphError::indicatesInvalidToken($body)) {
throw new TokenExpiredException('Threads access token is invalid or expired');
}
return $response->successful();

View file

@ -126,7 +126,16 @@ private function sendWithMedia(string $channelId, array $payload, Collection $me
$filename = $item->original_filename ?: (basename($item->path) ?: "media-{$index}");
$request = $request->attach("files[{$index}]", file_get_contents($tempFile), $filename);
$attachments[] = ['id' => $index, 'filename' => $filename];
$attachment = ['id' => $index, 'filename' => $filename];
$alt = $item->isImage() ? $item->altTextFor(Platform::Discord) : null;
if ($alt !== null) {
$attachment['description'] = $alt;
}
$attachments[] = $attachment;
}
$payload['attachments'] = $attachments;

View file

@ -5,6 +5,7 @@
namespace App\Services\Social;
use App\Enums\PostPlatform\ContentType;
use App\Enums\SocialAccount\Platform;
use App\Exceptions\Social\ErrorCategory;
use App\Exceptions\Social\FacebookPublishException;
use App\Exceptions\Social\SocialPublishException;
@ -134,6 +135,12 @@ private function publishSingleImagePost(string $pageId, string $accessToken, ?st
$payload['message'] = $content;
}
$alt = $media->altTextFor(Platform::Facebook);
if ($alt !== null) {
$payload['alt_text_custom'] = $alt;
}
$response = $this->facebookHttp()->post("{$this->baseUrl}/{$pageId}/photos", $payload);
if ($response->failed()) {
@ -163,11 +170,19 @@ private function publishMultiImagePost(string $pageId, string $accessToken, ?str
continue;
}
$uploadResponse = $this->facebookHttp()->post("{$this->baseUrl}/{$pageId}/photos", [
$uploadPayload = [
'url' => $this->cropImageForAspectRatio($media->url, $aspectRatio),
'published' => 'false',
'access_token' => $accessToken,
]);
];
$alt = $media->altTextFor(Platform::Facebook);
if ($alt !== null) {
$uploadPayload['alt_text_custom'] = $alt;
}
$uploadResponse = $this->facebookHttp()->post("{$this->baseUrl}/{$pageId}/photos", $uploadPayload);
if ($uploadResponse->failed()) {
Log::error('Facebook image upload failed', [

View file

@ -44,7 +44,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array
$this->baseUrl = $account->platform->instagramGraphBaseUrl();
if ($account->is_token_expired || $account->is_token_expiring_soon) {
if ($account->needsProactiveTokenRefresh()) {
app(ConnectionVerifier::class)->refreshToken($account);
}
@ -87,7 +87,7 @@ private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $si
{
$this->baseUrl = $account->platform->instagramGraphBaseUrl();
if ($account->is_token_expired || $account->is_token_expiring_soon) {
if ($account->needsProactiveTokenRefresh()) {
app(ConnectionVerifier::class)->refreshToken($account);
}

View file

@ -5,6 +5,7 @@
namespace App\Services\Social;
use App\Enums\PostPlatform\ContentType;
use App\Enums\SocialAccount\Platform;
use App\Exceptions\Social\ErrorCategory;
use App\Exceptions\Social\InstagramPublishException;
use App\Exceptions\Social\SocialPublishException;
@ -28,7 +29,7 @@ public function publish(PostPlatform $postPlatform): array
$account = $postPlatform->socialAccount;
$this->baseUrl = $account->platform->instagramGraphBaseUrl();
if ($account->is_token_expired || $account->is_token_expiring_soon) {
if ($account->needsProactiveTokenRefresh()) {
app(ConnectionVerifier::class)->refreshToken($account);
}
@ -81,12 +82,20 @@ private function publishSingleImage(string $instagramId, string $accessToken, ?s
{
$imageUrl = $this->cropImageForAspectRatio($media->url, $aspectRatio);
// Step 1: Create container
$containerResponse = $this->socialHttp()->post("{$this->baseUrl}/{$instagramId}/media", [
$params = [
'image_url' => $imageUrl,
'caption' => $content,
'access_token' => $accessToken,
]);
];
$alt = $media->altTextFor(Platform::Instagram);
if ($alt !== null) {
$params['alt_text'] = $alt;
}
// Step 1: Create container
$containerResponse = $this->socialHttp()->post("{$this->baseUrl}/{$instagramId}/media", $params);
if ($containerResponse->failed()) {
Log::error('Instagram container creation failed', [
@ -158,7 +167,8 @@ private function publishStory(string $instagramId, string $accessToken, $media):
if ($isVideo) {
$params['video_url'] = $media->url;
} else {
$params['image_url'] = $media->url;
$dimensions = ContentType::InstagramStory->aiImageDimensions();
$params['image_url'] = $this->fitImageToCanvas($media->url, data_get($dimensions, 'width'), data_get($dimensions, 'height'));
}
// Step 1: Create story container
@ -206,6 +216,12 @@ private function publishCarousel(string $instagramId, string $accessToken, ?stri
$params['media_type'] = 'VIDEO';
} else {
$params['image_url'] = $this->cropImageForAspectRatio($media->url, $aspectRatio);
$alt = $media->altTextFor(Platform::Instagram);
if ($alt !== null) {
$params['alt_text'] = $alt;
}
}
$containerResponse = $this->socialHttp()->post("{$this->baseUrl}/{$instagramId}/media", $params);

View file

@ -50,7 +50,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array
return ['unsupported' => true, 'reason' => 'missing_post_id'];
}
if ($account->is_token_expired || $account->is_token_expiring_soon) {
if ($account->needsProactiveTokenRefresh()) {
app(ConnectionVerifier::class)->refreshToken($account);
}
@ -79,7 +79,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array
private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $since, CarbonInterface $until): array
{
if ($account->is_token_expired || $account->is_token_expiring_soon) {
if ($account->needsProactiveTokenRefresh()) {
app(ConnectionVerifier::class)->refreshToken($account);
}

View file

@ -33,7 +33,7 @@ public function publish(PostPlatform $postPlatform): array
// Upload media first (max 4)
foreach ($medias->take(4) as $media) {
$mediaId = $this->uploadMedia($account, $instance, $media->url, $media->original_filename);
$mediaId = $this->uploadMedia($account, $instance, $media->url, $media->original_filename, $media->isImage() ? $media->altTextFor(Platform::Mastodon) : null);
if ($mediaId) {
$mediaIds[] = $mediaId;
}
@ -68,7 +68,7 @@ public function publish(PostPlatform $postPlatform): array
];
}
private function uploadMedia(SocialAccount $account, string $instance, string $url, ?string $filename): ?string
private function uploadMedia(SocialAccount $account, string $instance, string $url, ?string $filename, ?string $altText): ?string
{
$tempFile = tempnam(sys_get_temp_dir(), 'masto_media_');
@ -101,9 +101,14 @@ private function uploadMedia(SocialAccount $account, string $instance, string $u
$stream = fopen($tempFile, 'r');
$response = $this->socialHttp()->withToken($account->access_token)
->attach('file', $stream, $name)
->post("{$instance}/api/v1/media");
$request = $this->socialHttp()->withToken($account->access_token)
->attach('file', $stream, $name);
if ($altText !== null) {
$request = $request->attach('description', $altText);
}
$response = $request->post("{$instance}/api/v1/media");
if (is_resource($stream)) {
fclose($stream);

View file

@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Services\Social\Meta;
/**
* Interprets Meta Graph API (Facebook / Instagram / Threads) error responses.
*
* Meta returns rate-limit and transient failures as an ordinary HTTP 4xx with
* type "OAuthException" (e.g. code 4 / 17 "too many calls", code 1 / 2
* "temporary problem"), so neither the HTTP status nor the error type can tell
* a dead token from a throttle. Only error code 190 means the access token
* itself is invalid or expired — the same signal the platform publish
* exceptions already use to decide a disconnect.
*/
class GraphError
{
/**
* Whether the given Meta Graph error body means the access token is
* genuinely invalid or expired (code 190), as opposed to a rate-limit or
* transient error that must not disconnect a still-valid token.
*
* @param array<string, mixed>|null $body
*/
public static function indicatesInvalidToken(?array $body): bool
{
return data_get($body, 'error.code') === 190;
}
}

View file

@ -46,7 +46,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array
return ['unsupported' => true, 'reason' => 'missing_post_id'];
}
if ($account->is_token_expired || $account->is_token_expiring_soon) {
if ($account->needsProactiveTokenRefresh()) {
app(ConnectionVerifier::class)->refreshToken($account);
}
@ -90,7 +90,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array
private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $since, CarbonInterface $until): array
{
if ($account->is_token_expired || $account->is_token_expiring_soon) {
if ($account->needsProactiveTokenRefresh()) {
app(ConnectionVerifier::class)->refreshToken($account);
}

View file

@ -34,7 +34,7 @@ public function publish(PostPlatform $postPlatform): array
$account = $postPlatform->socialAccount;
if ($account->is_token_expired || $account->is_token_expiring_soon) {
if ($account->needsProactiveTokenRefresh()) {
app(ConnectionVerifier::class)->refreshToken($account);
}
@ -119,8 +119,10 @@ private function publishImagePin(PostPlatform $postPlatform, ?string $content):
$payload['link'] = data_get($postPlatform->meta, 'link');
}
if (! empty(data_get($postPlatform->meta, 'alt_text'))) {
$payload['alt_text'] = substr(data_get($postPlatform->meta, 'alt_text'), 0, 500);
$alt = $postPlatform->post->mediaItems->first(fn ($m) => $m->isImage())?->altTextFor(Platform::Pinterest);
if ($alt !== null) {
$payload['alt_text'] = $alt;
}
$response = $this->socialHttp()->withToken($account->access_token)
@ -407,7 +409,7 @@ private function waitForMediaProcessing(SocialAccount $account, string $mediaId,
*/
public function getBoards(SocialAccount $account): array
{
if ($account->is_token_expired || $account->is_token_expiring_soon) {
if ($account->needsProactiveTokenRefresh()) {
app(ConnectionVerifier::class)->refreshToken($account);
}

View file

@ -46,7 +46,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array
return ['unsupported' => true, 'reason' => 'missing_post_id'];
}
if ($account->is_token_expired || $account->is_token_expiring_soon) {
if ($account->needsProactiveTokenRefresh()) {
app(ConnectionVerifier::class)->refreshToken($account);
}
@ -77,7 +77,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array
private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $since, CarbonInterface $until): array
{
if ($account->is_token_expired || $account->is_token_expiring_soon) {
if ($account->needsProactiveTokenRefresh()) {
app(ConnectionVerifier::class)->refreshToken($account);
}

View file

@ -4,6 +4,7 @@
namespace App\Services\Social;
use App\Enums\SocialAccount\Platform;
use App\Exceptions\Social\ErrorCategory;
use App\Exceptions\Social\ThreadsPublishException;
use App\Models\PostPlatform;
@ -30,7 +31,7 @@ public function publish(PostPlatform $postPlatform): array
$account = $postPlatform->socialAccount;
if ($account->is_token_expired || $account->is_token_expiring_soon) {
if ($account->needsProactiveTokenRefresh()) {
app(ConnectionVerifier::class)->refreshToken($account);
}
@ -100,12 +101,20 @@ private function publishTextPost(string $userId, string $accessToken, string $co
private function publishImagePost(string $userId, string $accessToken, ?string $content, $media): array
{
// Step 1: Create container
$containerResponse = $this->socialHttp()->post("{$this->baseUrl}/{$userId}/threads", [
$params = [
'media_type' => 'IMAGE',
'image_url' => $media->url,
'text' => $content,
'access_token' => $accessToken,
]);
];
$alt = $media->altTextFor(Platform::Threads);
if ($alt !== null) {
$params['alt_text'] = $alt;
}
$containerResponse = $this->socialHttp()->post("{$this->baseUrl}/{$userId}/threads", $params);
if ($containerResponse->failed()) {
Log::error('Threads image container creation failed', [
@ -184,6 +193,12 @@ private function publishCarousel(string $userId, string $accessToken, ?string $c
} else {
$params['media_type'] = 'IMAGE';
$params['image_url'] = $media->url;
$alt = $media->altTextFor(Platform::Threads);
if ($alt !== null) {
$params['alt_text'] = $alt;
}
}
$containerResponse = $this->socialHttp()->post("{$this->baseUrl}/{$userId}/threads", $params);

View file

@ -35,7 +35,7 @@ public function getMetrics(SocialAccount $account): array
private function fetchMetricsFromApi(SocialAccount $account): array
{
if ($account->is_token_expired || $account->is_token_expiring_soon) {
if ($account->needsProactiveTokenRefresh()) {
app(ConnectionVerifier::class)->refreshToken($account);
}

View file

@ -58,7 +58,7 @@ public function fetch(SocialAccount $account): array
*/
private function fetchFresh(SocialAccount $account): array
{
if ($account->is_token_expired || $account->is_token_expiring_soon) {
if ($account->needsProactiveTokenRefresh()) {
app(ConnectionVerifier::class)->refreshToken($account);
}

View file

@ -43,7 +43,7 @@ public function publish(PostPlatform $postPlatform): array
$account = $postPlatform->socialAccount;
if ($account->is_token_expired || $account->is_token_expiring_soon) {
if ($account->needsProactiveTokenRefresh()) {
app(ConnectionVerifier::class)->refreshToken($account);
}

View file

@ -16,13 +16,18 @@
* Normalizes the failure modes of an OAuth token-refresh request:
*
* - ConnectionException (timeout / DNS / refused) → PlatformUnavailableException
* - HTTP 5xx → PlatformUnavailableException
* - HTTP 4xx → TokenExpiredException
* - HTTP 5xx or 429 → PlatformUnavailableException
* - other HTTP 4xx → TokenExpiredException
*
* Providers that return rate-limit or transient errors as an ordinary 4xx
* (Meta: Instagram/Threads) can pass an `$isTokenInvalid` classifier to
* `send()`; only a genuinely dead token then disconnects, while every other
* 4xx is treated as transient (PlatformUnavailableException).
*
* Callers configure the actual HTTP call through the closure passed to
* `send()`, so platform-specific quirks (form vs JSON body, auth headers,
* basic auth, etc.) stay where they belong — in the per-platform refresh
* method — while the failure semantics are uniform across providers.
* method.
*/
class TokenRefreshClient
{
@ -35,11 +40,14 @@ public static function for(Platform $platform): self
/**
* @param Closure():Response $request
* @param (Closure(array<string, mixed>|null):bool)|null $isTokenInvalid Given the parsed
* response body, returns whether a non-5xx/429 failure means the token itself is dead.
* When it returns false, the failure is treated as transient (PlatformUnavailableException).
*
* @throws PlatformUnavailableException
* @throws TokenExpiredException
*/
public function send(Closure $request): Response
public function send(Closure $request, ?Closure $isTokenInvalid = null): Response
{
$name = $this->platform->label();
@ -62,6 +70,14 @@ public function send(Closure $request): Response
]);
$body = $response->json();
if ($isTokenInvalid !== null && ! $isTokenInvalid($body)) {
throw new PlatformUnavailableException(
"{$name} API returned {$response->status()} during token refresh",
$response->status(),
);
}
$message = data_get($body, 'error_description')
?? data_get($body, 'error.message')
?? "Failed to refresh {$name} token";

View file

@ -46,7 +46,7 @@ public function getMetrics(SocialAccount $account, ?CarbonInterface $since = nul
private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $since, CarbonInterface $until): array
{
if ($account->is_token_expired || $account->is_token_expiring_soon) {
if ($account->needsProactiveTokenRefresh()) {
app(ConnectionVerifier::class)->refreshToken($account);
}
@ -161,7 +161,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array
return ['unsupported' => true, 'reason' => 'missing_post_id'];
}
if ($account->is_token_expired || $account->is_token_expiring_soon) {
if ($account->needsProactiveTokenRefresh()) {
app(ConnectionVerifier::class)->refreshToken($account);
}

View file

@ -4,6 +4,7 @@
namespace App\Services\Social;
use App\DataTransferObjects\MediaItem;
use App\Enums\Media\Type as MediaType;
use App\Enums\SocialAccount\Platform;
use App\Exceptions\Social\ErrorCategory;
@ -15,6 +16,7 @@
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Throwable;
class XPublisher
{
@ -37,8 +39,7 @@ public function publish(PostPlatform $postPlatform): array
$account = $postPlatform->socialAccount;
// Refresh token if expired or expiring soon
if ($account->is_token_expired || $account->is_token_expiring_soon) {
if ($account->needsProactiveTokenRefresh()) {
app(ConnectionVerifier::class)->refreshToken($account);
}
@ -61,6 +62,7 @@ public function publish(PostPlatform $postPlatform): array
$mediaId = data_get($uploadedMedia, 'data.id', data_get($uploadedMedia, 'media_id'));
if ($mediaId) {
$mediaIds[] = $mediaId;
$this->uploadAltText($mediaId, $mediaItem);
}
}
}
@ -107,6 +109,41 @@ private function getHttpClient(): PendingRequest
]);
}
/**
* Sets the image's accessibility description on X via the v2 media
* metadata endpoint. Best-effort: only images carry alt text, and a failure
* here never blocks the tweet — the media already uploaded and the post
* should still go out without the description.
*/
private function uploadAltText(string $mediaId, MediaItem $mediaItem): void
{
if (! $mediaItem->isImage()) {
return;
}
$alt = $mediaItem->altTextFor(Platform::X);
if ($alt === null) {
return;
}
try {
$this->getHttpClient()->post("{$this->baseUrl}/media/metadata", [
'id' => $mediaId,
'metadata' => [
'alt_text' => [
'text' => $alt,
],
],
]);
} catch (Throwable $e) {
Log::warning('X alt text upload failed; posting the tweet without it', [
'media_id' => $mediaId,
'error' => $e->getMessage(),
]);
}
}
private function uploadMedia($mediaItem): ?array
{
$mimeType = $mediaItem->mime_type;

View file

@ -46,7 +46,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array
return ['unsupported' => true, 'reason' => 'missing_post_id'];
}
if ($account->is_token_expired || $account->is_token_expiring_soon) {
if ($account->needsProactiveTokenRefresh()) {
app(ConnectionVerifier::class)->refreshToken($account);
}
@ -97,7 +97,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array
private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $since, CarbonInterface $until): array
{
if ($account->is_token_expired || $account->is_token_expiring_soon) {
if ($account->needsProactiveTokenRefresh()) {
app(ConnectionVerifier::class)->refreshToken($account);
}

View file

@ -33,7 +33,7 @@ public function publish(PostPlatform $postPlatform): array
$account = $postPlatform->socialAccount;
if ($account->is_token_expired || $account->is_token_expiring_soon) {
if ($account->needsProactiveTokenRefresh()) {
app(ConnectionVerifier::class)->refreshToken($account);
}

View file

@ -15,7 +15,7 @@ class UnsplashService
public function __construct()
{
$this->accessKey = config('services.unsplash.access_key', '');
$this->accessKey = (string) config('services.unsplash.access_key', '');
}
/**

View file

@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Support\Billing;
use App\Models\Account;
use Laravel\Cashier\SubscriptionBuilder;
use RuntimeException;
use Stripe\Subscription as StripeSubscription;
final class FirstMonthCheckoutDiscount
{
/**
* Configure a subscription checkout to charge $1 for the first invoice via
* a `duration: once` Stripe coupon, so a real charge validates the card
* instead of a $0 trial authorization. Stripe rejects a Checkout Session
* that sets both `discounts` and `allow_promotion_codes`, so checkouts that
* don't qualify for the paid first month keep the promotion-code field.
*
* @throws RuntimeException when a qualifying checkout has the paid first
* month enabled but no coupon configured — failing
* loudly beats silently charging full price.
*/
public static function apply(SubscriptionBuilder $subscription, Account $account): SubscriptionBuilder
{
if (! self::qualifiesForPaidFirstMonth($account)) {
return $subscription->allowPromotionCodes();
}
$couponId = config('cashier.first_month_coupon_id');
if (! is_string($couponId) || $couponId === '') {
throw new RuntimeException(
'STRIPE_FIRST_MONTH_COUPON_ID must be set when REQUIRE_CARD_FOR_TRIAL is enabled, '
.'otherwise checkout would charge the full price instead of the $1 first month.'
);
}
return $subscription->withCoupon($couponId);
}
/**
* The fixed-amount first-month coupon only applies to a genuinely new
* customer checking out a single workspace: the fixed `amount_off` is only
* correct for a quantity of one, and the $1 offer is for first-time signups
* — not a returning account re-subscribing with workspaces it kept from a
* lapsed subscription. A subscription that never left `incomplete` never
* became real, so a new customer retrying after a failed first attempt
* still qualifies; any started subscription (even canceled) does not.
*/
private static function qualifiesForPaidFirstMonth(Account $account): bool
{
if (! (bool) config('trypost.billing.require_card_for_trial', true)) {
return false;
}
return $account->workspaces()->count() === 1
&& ! $account->subscriptions()
->whereNotIn('stripe_status', [
StripeSubscription::STATUS_INCOMPLETE,
StripeSubscription::STATUS_INCOMPLETE_EXPIRED,
])
->exists();
}
}

View file

@ -5,6 +5,7 @@
namespace App\Support;
use App\Enums\Media\Source;
use Closure;
use Illuminate\Validation\Rule;
/**
@ -15,6 +16,12 @@
*/
class PostMediaRules
{
/**
* Maximum stored length (characters) for a media item's alt text. Publishers
* truncate further to each platform's own cap via Platform::altTextMaxLength().
*/
public const ALT_TEXT_MAX_LENGTH = 2000;
/**
* @param bool $hosted true (web): items must already be hosted (id + path
* required); false (API): a bare external `url` is
@ -34,7 +41,23 @@ public static function rules(bool $hosted): array
'media.*.mime_type' => ['sometimes', 'nullable', 'string', 'max:255'],
'media.*.original_filename' => ['sometimes', 'nullable', 'string', 'max:500'],
'media.*.size' => ['sometimes', 'nullable', 'integer'],
'media.*.meta' => ['sometimes', 'nullable', 'array'],
'media.*.meta' => ['sometimes', 'nullable', 'array', static function (string $attribute, mixed $value, Closure $fail): void {
$altText = data_get($value, 'alt_text');
if ($altText === null) {
return;
}
if (! is_string($altText)) {
$fail('validation.string')->translate(['attribute' => trans('posts.edit.alt_text.label')]);
return;
}
if (mb_strlen($altText) > self::ALT_TEXT_MAX_LENGTH) {
$fail('validation.max.string')->translate(['attribute' => trans('posts.edit.alt_text.label'), 'max' => self::ALT_TEXT_MAX_LENGTH]);
}
}],
'media.*.source' => ['sometimes', 'nullable', 'string', Rule::in(array_column(Source::cases(), 'value'))],
'media.*.source_meta' => ['sometimes', 'nullable', 'array'],
];

View file

@ -74,6 +74,7 @@
"mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.6",
"pestphp/pest": "^4.4",
"pestphp/pest-plugin-browser": "^4.3",
"pestphp/pest-plugin-laravel": "^4.1"
},
"autoload": {
@ -120,6 +121,10 @@
"@test:lint",
"@php artisan test"
],
"test:all": [
"@test",
"@php artisan test tests/Browser"
],
"post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
"@php artisan package:discover --ansi"

1573
composer.lock generated

File diff suppressed because it is too large Load diff

View file

@ -137,4 +137,19 @@
'trial_days' => env('CASHIER_TRIAL_DAYS', 8),
/*
|--------------------------------------------------------------------------
| Paid First Month Coupon
|--------------------------------------------------------------------------
|
| Stripe Coupon ID applied at checkout so the first invoice comes out to
| $1 instead of the full monthly price — a real charge validates the
| card up front instead of a $0 trial authorization. Must be an
| `amount_off` coupon with `duration: once`, so it discounts only the
| first invoice and the full price bills automatically afterward.
|
*/
'first_month_coupon_id' => env('STRIPE_FIRST_MONTH_COUPON_ID'),
];

View file

@ -19,6 +19,18 @@
'en' => 'English',
'es' => 'Español',
'pt-BR' => 'Português',
'fr' => 'Français',
'de' => 'Deutsch',
'it' => 'Italiano',
'nl' => 'Nederlands',
'pl' => 'Polski',
'el' => 'Ελληνικά',
'ja' => '日本語',
'ko' => '한국어',
'zh' => '中文',
'ru' => 'Русский',
'tr' => 'Türkçe',
'ar' => 'العربية',
],
/*

148
lang/ar/accounts.php Normal file
View file

@ -0,0 +1,148 @@
<?php
return [
'title' => 'الاتصالات',
'page_title' => 'الحسابات الاجتماعية',
'description' => 'نظرة عامة على جميع حساباتك الاجتماعية المتصلة',
'connect_cta' => 'ربط',
'not_connected' => 'غير متصل',
'connect' => 'ربط',
'connection_lost' => 'انقطع الاتصال',
'reconnect' => 'إعادة الربط',
'reconnect_account' => 'إعادة ربط الحساب',
'view_profile' => 'عرض الملف الشخصي',
'disconnect' => 'فصل',
'descriptions' => [
'linkedin' => 'اربط ملفك الشخصي أو صفحة شركتك على LinkedIn',
'linkedin-page' => 'اربط صفحة شركة على LinkedIn',
'x' => 'اربط حسابك على X (Twitter)',
'tiktok' => 'اربط حسابك على TikTok',
'youtube' => 'اربط قناة على YouTube',
'facebook' => 'اربط صفحة على Facebook',
'instagram' => 'اربط حساب Instagram احترافيًا',
'instagram-facebook' => 'اربط Instagram عبر صفحة Facebook',
'threads' => 'اربط حسابك على Threads',
'pinterest' => 'اربط حسابك على Pinterest',
'bluesky' => 'اربط حسابك على Bluesky',
'mastodon' => 'اربط حسابك على Mastodon',
'telegram' => 'اربط قناة أو مجموعة على Telegram',
'discord' => 'اربط خادم Discord',
],
'disconnect_modal' => [
'title' => 'فصل الحساب',
'description' => 'هل أنت متأكد من رغبتك في فصل هذا الحساب؟ يمكنك إعادة ربطه في أي وقت.',
'confirm' => 'فصل',
'cancel' => 'إلغاء',
],
'bluesky' => [
'title' => 'ربط Bluesky',
'description' => 'أدخل بيانات اعتمادك للربط',
'email' => 'البريد الإلكتروني',
'email_placeholder' => 'yourhandle.bsky.social',
'app_password' => 'كلمة مرور التطبيق',
'app_password_placeholder' => 'xxxx-xxxx-xxxx-xxxx',
'app_password_hint' => 'استخدم <strong>كلمة مرور التطبيق</strong> لمزيد من الأمان. أنشئ واحدة من <a href="https://bsky.app/settings/app-passwords" target="_blank" class="underline">bsky.app/settings</a>.',
'submit' => 'ربط Bluesky',
'submitting' => 'جارٍ الاتصال...',
],
'mastodon' => [
'title' => 'ربط Mastodon',
'description' => 'أدخل خادم Mastodon الخاص بك',
'instance_url' => 'رابط الخادم',
'instance_placeholder' => 'https://mastodon.social',
'instance_hint' => 'أدخل رابط خادم Mastodon الخاص بك (مثل mastodon.social، techhub.social)',
'submit' => 'المتابعة عبر Mastodon',
'submitting' => 'جارٍ الاتصال...',
],
'telegram' => [
'title' => 'ربط Telegram',
'description' => 'اربط قناة أو مجموعة',
'step_admin' => 'أضِف :bot كمشرف على قناتك أو مجموعتك على Telegram.',
'step_command' => 'انشر هذا الأمر في القناة أو المجموعة:',
'waiting' => 'في انتظار اتصال القناة…',
'connected' => 'تم اتصال القناة!',
'connected_toast' => 'تم ربط قناة Telegram بنجاح!',
'copied_toast' => 'تم نسخ الأمر إلى الحافظة',
'copy_tooltip' => 'نسخ الأمر',
'expired' => 'انتهت صلاحية هذا الرمز. أنشئ رمزًا جديدًا للمحاولة مرة أخرى.',
'new_code' => 'إنشاء رمز جديد',
'retry' => 'إعادة المحاولة',
'error_generic' => 'تعذر بدء الاتصال. يرجى المحاولة مرة أخرى.',
'network_taken' => 'تحتوي مساحة العمل هذه بالفعل على قناة Telegram متصلة. افصلها أولًا.',
],
'facebook' => [
'title' => 'اختيار صفحة Facebook',
'description' => 'اختر الصفحة التي تريد ربطها',
'no_pages' => 'لم يتم العثور على صفحات',
'no_pages_description' => 'أنت لست مشرفًا على أي صفحة Facebook.',
'page_label' => 'صفحة Facebook',
'view' => 'عرض',
'choose' => 'اختيار',
],
'instagram_facebook' => [
'title' => 'اختيار حساب Instagram',
'description' => 'اختر حساب Instagram الذي تريد ربطه',
'no_pages' => 'لم يتم العثور على حسابات Instagram',
'no_pages_description' => 'لم يتم العثور على صفحات Facebook مرتبطة بحسابات Instagram للأعمال.',
'view' => 'عرض',
'choose' => 'اختيار',
],
'linkedin' => [
'title' => 'اختيار صفحة LinkedIn',
'description' => 'اختر الصفحة التي تريد ربطها',
'no_pages' => 'لم يتم العثور على صفحات',
'no_pages_description' => 'أنت لست مشرفًا على أي صفحة LinkedIn.',
'page_label' => 'صفحة LinkedIn',
'select_title' => 'أين تريد النشر؟',
'select_subtitle' => 'انشر باسمك أو اختر صفحة شركة تديرها.',
'person_tag' => 'شخص',
'organization_tag' => 'مؤسسة',
'view' => 'عرض',
'choose' => 'اختيار',
],
'flash' => [
'disconnected' => 'تم فصل الحساب بنجاح!',
'connected' => 'تم ربط الحساب بنجاح!',
'session_expired' => 'انتهت الجلسة. يرجى المحاولة مرة أخرى.',
'workspace_not_found' => 'لم يتم العثور على مساحة العمل.',
'activated' => 'تم تفعيل الحساب!',
'deactivated' => 'تم إلغاء تفعيل الحساب!',
'already_connected' => 'هذه المنصة متصلة بالفعل.',
'no_youtube_channels' => 'لم يتم العثور على قنوات YouTube. يرجى إنشاء قناة أولًا.',
],
'popup_callback' => [
'title_success' => 'تم الاتصال',
'title_error' => 'خطأ',
'closing' => 'ستُغلق هذه النافذة تلقائيًا...',
'manual_close' => 'يمكنك إغلاق هذه النافذة.',
'popup_blocked' => 'تعذر فتح نافذة الاتصال. يرجى السماح بالنوافذ المنبثقة والمحاولة مرة أخرى.',
'connected' => 'تم ربط الحساب!',
'reconnected' => 'تمت إعادة ربط الحساب!',
'error_connecting' => 'خطأ في ربط الحساب. يرجى المحاولة مرة أخرى.',
'network_taken' => 'تحتوي مساحة العمل هذه بالفعل على حساب لهذه الشبكة. افصله أولًا.',
'error_connecting_page' => 'خطأ في ربط الصفحة. يرجى المحاولة مرة أخرى.',
'error_connecting_channel' => 'خطأ في ربط القناة. يرجى المحاولة مرة أخرى.',
'session_expired' => 'انتهت الجلسة. يرجى المحاولة مرة أخرى.',
'workspace_not_found' => 'لم يتم العثور على مساحة العمل.',
'invalid_state' => 'حالة غير صالحة. يرجى المحاولة مرة أخرى.',
'failed_to_authenticate' => 'فشلت المصادقة.',
'failed_to_get_profile' => 'فشل جلب الملف الشخصي.',
'page_not_found' => 'لم يتم العثور على الصفحة.',
'channel_not_found' => 'لم يتم العثور على القناة.',
'no_facebook_pages' => 'لم يتم العثور على صفحات Facebook. يجب أن تكون مشرفًا على صفحة واحدة على الأقل.',
'no_facebook_instagram_pages' => 'لم يتم العثور على صفحات Facebook مرتبطة بحسابات Instagram.',
'no_youtube_channels' => 'لم يتم العثور على قنوات YouTube. يرجى إنشاء قناة أولًا.',
'not_linkedin_admin' => 'أنت لست مشرفًا على أي صفحة LinkedIn.',
],
];

55
lang/ar/analytics.php Normal file
View file

@ -0,0 +1,55 @@
<?php
return [
'no_accounts' => 'لا توجد حسابات متصلة تحتوي على تحليلات.',
'no_accounts_match' => 'لا توجد حسابات مطابقة.',
'search_account' => 'البحث عن حساب…',
'select_account' => 'اختر حسابًا لعرض التحليلات.',
'no_data' => 'لا تتوفر بيانات تحليلات.',
'metrics' => [
'avg_view_duration' => 'متوسط مدة المشاهدة (ث)',
'avg_view_percentage' => 'متوسط نسبة المشاهدة',
'bookmarks' => 'الإشارات المرجعية',
'clicks' => 'النقرات',
'comments' => 'التعليقات',
'custom_reaction' => 'مخصص',
'engagement' => 'التفاعل',
'favourites' => 'المفضلة',
'followers' => 'المتابِعون',
'following' => 'المتابَعون',
'impressions' => 'مرات الظهور',
'interactions' => 'التفاعلات',
'likes' => 'الإعجابات',
'members' => 'الأعضاء',
'minutes_watched' => 'دقائق المشاهدة',
'organic_followers' => 'المتابِعون العضويون',
'outbound_clicks' => 'النقرات الصادرة',
'page_followers' => 'متابِعو الصفحة',
'page_reach' => 'وصول الصفحة',
'page_views' => 'مشاهدات الصفحة',
'paid_followers' => 'المتابِعون المدفوعون',
'pin_click_rate' => 'معدل النقر على الدبوس',
'pin_clicks' => 'نقرات الدبوس',
'posts_engagement' => 'تفاعل المنشورات',
'posts_reach' => 'وصول المنشورات',
'quotes' => 'الاقتباسات',
'reach' => 'الوصول',
'reblogs' => 'إعادات التدوين',
'recent_comments' => 'أحدث التعليقات',
'recent_likes' => 'أحدث الإعجابات',
'recent_shares' => 'أحدث المشاركات',
'replies' => 'الردود',
'reposts' => 'إعادات النشر',
'retweets' => 'إعادات التغريد',
'saves' => 'مرات الحفظ',
'shares' => 'المشاركات',
'subscribers' => 'المشتركون',
'subscribers_gained' => 'المشتركون الجدد',
'subscribers_lost' => 'المشتركون المفقودون',
'total_likes' => 'إجمالي الإعجابات',
'video_views' => 'مشاهدات الفيديو',
'videos' => 'مقاطع الفيديو',
'views' => 'المشاهدات',
],
];

52
lang/ar/assets.php Normal file
View file

@ -0,0 +1,52 @@
<?php
return [
'title' => 'الوسائط',
'tabs' => [
'my_uploads' => 'ملفاتي المرفوعة',
'stock_photos' => 'صور مجانية',
'gifs' => 'صور GIF',
],
'upload' => [
'drag_drop' => 'اسحب ملفاتك وأفلتها هنا، أو انقر للاختيار',
'formats' => 'JPEG، PNG، GIF، WebP، MP4، PDF',
'uploading' => 'جارٍ الرفع...',
'failed' => 'تعذر رفع :file. يرجى المحاولة مرة أخرى.',
],
'empty' => [
'title' => 'لا توجد وسائط بعد',
'description' => 'ارفع الصور ومقاطع الفيديو لبناء مكتبة الوسائط الخاصة بك.',
],
'save_to_assets' => 'حفظ في الوسائط',
'saved' => 'تم الحفظ في وسائطك!',
'create_post' => 'إنشاء منشور',
'add_to_post' => 'إضافة إلى المنشور',
'search_placeholder' => 'البحث في الوسائط...',
'delete' => [
'title' => 'حذف الوسائط',
'description' => 'هل أنت متأكد من رغبتك في حذف هذا العنصر؟ لا يمكن التراجع عن هذا الإجراء.',
'confirm' => 'حذف',
'cancel' => 'إلغاء',
],
'unsplash' => [
'search_placeholder' => 'البحث عن صور مجانية...',
'no_results' => 'لم يتم العثور على صور',
'no_results_description' => 'جرّب كلمة بحث مختلفة.',
'trending' => 'الرائج على Unsplash',
'start_searching' => 'ابحث عن صور مجانية من Unsplash',
],
'giphy' => [
'trending' => 'الرائج على Giphy',
'search_placeholder' => 'البحث عن صور GIF...',
'no_results' => 'لم يتم العثور على صور GIF',
'no_results_description' => 'جرّب كلمة بحث مختلفة.',
'powered_by' => 'مقدَّم من GIPHY',
],
];

141
lang/ar/auth.php Normal file
View file

@ -0,0 +1,141 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Authentication Language Lines
|--------------------------------------------------------------------------
|
| The following language lines are used during authentication for various
| messages that we need to display to the user. You are free to modify
| these language lines according to your application's requirements.
|
*/
'failed' => 'بيانات الاعتماد هذه لا تطابق سجلاتنا.',
'password' => 'كلمة المرور المُدخلة غير صحيحة.',
'throttle' => 'عدد كبير جدًا من محاولات تسجيل الدخول. يرجى المحاولة مرة أخرى بعد :seconds ثانية.',
'flash' => [
'welcome' => 'مرحبًا بك في TryPost!',
'welcome_trial' => 'مرحبًا بك في TryPost! لقد بدأت فترتك التجريبية.',
],
'legal' => 'بمتابعتك، فإنك توافق على <a href="https://trypost.it/terms" target="_blank">شروط الخدمة</a> و<a href="https://trypost.it/privacy" target="_blank">سياسة الخصوصية</a>.',
'slides' => [
'calendar' => [
'title' => 'تقويم مرئي',
'description' => 'خطّط لمحتواك وجدوِله باستخدام تقويم بديهي يعتمد على السحب والإفلات عبر جميع حساباتك الاجتماعية.',
],
'scheduling' => [
'title' => 'جدولة ذكية',
'description' => 'جدوِل المنشورات عبر LinkedIn وX وInstagram وTikTok وYouTube والمزيد — كل ذلك من مكان واحد.',
],
'media' => [
'title' => 'وسائط غنية',
'description' => 'انشر الصور والعروض الدائرية والقصص ومقاطع الريلز. تحصل كل منصة على التنسيق المناسب تلقائيًا.',
],
'video' => [
'title' => 'نشر الفيديو',
'description' => 'ارفع مقاطع الفيديو مرة واحدة وانشرها على TikTok وYouTube Shorts وInstagram Reels وFacebook Reels.',
],
'team' => [
'title' => 'مساحات عمل جماعية',
'description' => 'ادعُ فريقك، وعيّن الأدوار، وأدِر عدة علامات تجارية من مساحات عمل منفصلة.',
],
'signatures' => [
'title' => 'التوقيعات',
'description' => 'احفظ توقيعات قابلة لإعادة الاستخدام (وسوم، روابط، خواتيم) وأضِفها إلى المنشورات بنقرة واحدة.',
],
],
'or_continue_with' => 'أو تابع باستخدام',
'or_continue_with_email' => 'أو تابع باستخدام البريد الإلكتروني',
'google_login' => 'تسجيل الدخول عبر Google',
'google_signup' => 'التسجيل عبر Google',
'github_login' => 'تسجيل الدخول عبر GitHub',
'github_signup' => 'التسجيل عبر GitHub',
'github_email_unavailable' => 'تعذر جلب بريدك الإلكتروني من GitHub. اجعل بريدك على GitHub عامًا أو امنح نطاق الوصول إلى البريد، ثم حاول مرة أخرى.',
'signup_success' => [
'page_title' => 'مرحبًا',
'title' => 'جارٍ إعداد حسابك',
'description' => 'يستغرق هذا عادةً بضع ثوانٍ فقط...',
],
'login' => [
'title' => 'تسجيل الدخول إلى حسابك',
'description' => 'أدخل بريدك الإلكتروني وكلمة المرور أدناه لتسجيل الدخول',
'page_title' => 'تسجيل الدخول',
'email' => 'البريد الإلكتروني',
'password' => 'كلمة المرور',
'forgot_password' => 'نسيت كلمة المرور؟',
'remember_me' => 'تذكّرني',
'submit' => 'تسجيل الدخول',
'no_account' => 'ليس لديك حساب؟',
'sign_up' => 'إنشاء حساب',
],
'register' => [
'title' => 'تقويمك الاجتماعي بالكامل، في مكان واحد',
'description' => 'أنشئ حسابك وابدأ جدولة المنشورات عبر كل شبكة.',
'page_title' => 'التسجيل',
'signup_with_email' => 'التسجيل بالبريد الإلكتروني',
'name' => 'الاسم',
'name_placeholder' => 'الاسم الكامل',
'email' => 'البريد الإلكتروني',
'password' => 'كلمة المرور',
'show_password' => 'إظهار كلمة المرور',
'hide_password' => 'إخفاء كلمة المرور',
'submit' => 'إنشاء حساب',
'has_account' => 'هل لديك حساب بالفعل؟',
'log_in' => 'تسجيل الدخول',
],
'forgot_password' => [
'title' => 'نسيت كلمة المرور',
'description' => 'أدخل بريدك الإلكتروني لاستلام رابط إعادة تعيين كلمة المرور',
'page_title' => 'نسيت كلمة المرور',
'email' => 'البريد الإلكتروني',
'submit' => 'إرسال رابط إعادة تعيين كلمة المرور',
'return_to' => 'أو، العودة إلى',
'log_in' => 'تسجيل الدخول',
],
'reset_password' => [
'title' => 'إعادة تعيين كلمة المرور',
'description' => 'يرجى إدخال كلمة المرور الجديدة أدناه',
'page_title' => 'إعادة تعيين كلمة المرور',
'email' => 'البريد الإلكتروني',
'password' => 'كلمة المرور',
'confirm_password' => 'تأكيد كلمة المرور',
'confirm_placeholder' => 'تأكيد كلمة المرور',
'submit' => 'إعادة تعيين كلمة المرور',
],
'verify_email' => [
'title' => 'تأكيد البريد الإلكتروني',
'description' => 'يرجى تأكيد بريدك الإلكتروني بالنقر على الرابط الذي أرسلناه إليك للتو.',
'page_title' => 'تأكيد البريد الإلكتروني',
'link_sent' => 'تم إرسال رابط تأكيد جديد إلى البريد الإلكتروني الذي قدّمته أثناء التسجيل.',
'resend' => 'إعادة إرسال بريد التأكيد',
'log_out' => 'تسجيل الخروج',
],
'accept_invite' => [
'page_title' => 'قبول الدعوة',
'title' => 'لقد تمت دعوتك!',
'description' => 'لقد تمت دعوتك للانضمام إلى مساحة العمل :workspace.',
'workspace' => 'مساحة العمل',
'your_role' => 'دورك',
'email' => 'البريد الإلكتروني',
'accept' => 'قبول الدعوة',
'decline' => 'رفض الدعوة',
'login_prompt' => 'سجّل الدخول أو أنشئ حسابًا لقبول هذه الدعوة.',
'log_in' => 'تسجيل الدخول',
'create_account' => 'إنشاء حساب',
],
];

411
lang/ar/automations.php Normal file
View file

@ -0,0 +1,411 @@
<?php
declare(strict_types=1);
return [
'title' => 'الأتمتة',
'default_name' => 'أتمتة جديدة',
'actions' => [
'new' => 'أتمتة جديدة',
'edit' => 'تعديل',
'save' => 'حفظ',
'activate' => 'تفعيل',
'pause' => 'إيقاف مؤقت',
'delete' => 'حذف',
'retry' => 'إعادة المحاولة',
'guide' => 'تعرّف على آلية العمل',
],
'tabs' => [
'build' => 'البناء',
'variables' => 'المتغيرات',
'test' => 'اختبار',
],
'nav' => [
'workflow' => 'سير العمل',
'invocations' => 'عمليات التشغيل',
'metrics' => 'المقاييس',
'settings' => 'الإعدادات',
],
'settings' => [
'general' => 'عام',
'general_description' => 'إعادة تسمية هذه الأتمتة.',
'name_label' => 'الاسم',
'name_saved' => 'تمت إعادة تسمية الأتمتة.',
'status_title' => 'الحالة',
'status_description' => 'فعّلها لبدء تشغيلها، أو أوقفها مؤقتًا للتوقف.',
'activated_at' => 'تم التفعيل :date',
'paused_at' => 'تم الإيقاف المؤقت :date',
'created_at' => 'تم الإنشاء :date',
'danger_title' => 'منطقة الخطر',
'danger_description' => 'إجراءات لا يمكن التراجع عنها.',
'delete_title' => 'حذف هذه الأتمتة',
'delete_description' => 'يزيل الأتمتة وسجل تشغيلها نهائيًا.',
],
'status_run' => [
'pending' => 'قيد الانتظار',
'running' => 'قيد التشغيل',
'waiting' => 'في الانتظار',
'completed' => 'مكتمل',
'failed' => 'فشل',
'cancelled' => 'مُلغى',
],
'node_type' => [
'trigger' => 'مُشغّل',
'generate' => 'إنشاء محتوى',
'delay' => 'تأخير',
'condition' => 'شرط',
'publish' => 'نشر',
'webhook' => 'Webhook',
'end' => 'إنهاء',
'fetch_rss' => 'جلب RSS',
'http_request' => 'طلب HTTP',
],
'invocations' => [
'empty' => 'لا توجد عمليات تشغيل بعد.',
'refresh' => 'تحديث',
'search_placeholder' => 'البحث عبر معرّف التشغيل…',
'copied' => 'تم نسخ معرّف التشغيل.',
'loading' => 'جارٍ تحميل الخطوات…',
'no_steps' => 'لم يتم تسجيل أي خطوات.',
'load_error' => 'تعذر تحميل الخطوات.',
'steps' => '{0}لا خطوات|{1}خطوة واحدة|{2}خطوتان|[3,10]:count خطوات|[11,*]:count خطوة',
'filter' => [
'all' => 'جميع الحالات',
],
'columns' => [
'timestamp' => 'الطابع الزمني',
'run' => 'التشغيل',
'status' => 'الحالة',
'message' => 'آخر رسالة',
'duration' => 'المدة',
],
'summary' => [
'completed' => 'اكتمل سير العمل',
'failed' => 'فشل سير العمل',
'running' => 'سير العمل قيد التشغيل',
'cancelled' => 'تم إلغاء سير العمل',
'pending' => 'سير العمل قيد الانتظار',
],
],
'metrics' => [
'overview' => 'نظرة عامة',
'runs_over_time' => 'عمليات التشغيل عبر الزمن',
'posts_by_platform' => 'المنشورات حسب المنصة',
'no_posts' => 'لم يتم نشر أي منشورات في هذه الفترة.',
'cards' => [
'runs' => 'إجمالي عمليات التشغيل',
'completed' => 'مكتملة',
'failed' => 'فاشلة',
'in_progress' => 'قيد التنفيذ',
'success_rate' => 'معدل النجاح',
'avg_duration' => 'متوسط المدة',
'posts_created' => 'المنشورات المُنشأة',
],
'legend' => [
'started' => 'بدأت',
'completed' => 'اكتملت',
'failed' => 'فشلت',
],
],
'categories' => [
'sources' => 'المصادر',
'content' => 'المحتوى',
'flow' => 'التدفق',
'output' => 'الإخراج',
],
'variables' => [
'title' => 'متغيرات سير العمل',
'hint' => 'قيم قابلة لإعادة الاستخدام يُشار إليها في أي مكان عبر {{ variables.KEY }}. تُخزَّن مشفّرة.',
'empty' => 'لا توجد متغيرات بعد.',
'key' => 'المفتاح',
'value' => 'القيمة',
'key_placeholder' => 'API_KEY',
'value_placeholder' => 'القيمة',
'add' => 'متغير جديد',
],
'expr' => [
'trigger_event' => 'اسم حدث المُشغّل',
'trigger_fired_at' => 'وقت إطلاق المُشغّل',
'trigger_post_id' => 'معرّف المنشور المُشغِّل',
'trigger_post_content' => 'محتوى المنشور المُشغِّل',
'trigger_post_status' => 'حالة المنشور المُشغِّل',
'trigger_post_scheduled_at' => 'وقت جدولة المنشور',
'trigger_post_published_at' => 'وقت نشر المنشور',
'fetched_title' => 'عنوان العنصر المجلوب',
'fetched_link' => 'رابط العنصر المجلوب',
'fetched_date' => 'تاريخ نشر العنصر المجلوب',
'fetched_content' => 'المحتوى الكامل للعنصر المجلوب',
'fetched_description' => 'ملخص العنصر المجلوب',
'fetched_author' => 'كاتب العنصر المجلوب',
'fetched_image' => 'رابط صورة العنصر المجلوب',
'fetched_categories' => 'فئات العنصر المجلوب',
'fetched_enclosure' => 'وسائط العنصر المجلوب (صوت/فيديو/ملف)',
'fetched_pubdate' => 'تاريخ نشر العنصر المجلوب',
'fetched_http' => 'عنصر HTTP المجلوب (أضِف حقلًا)',
'generated_content' => 'محتوى المنشور المُنشأ بالذكاء الاصطناعي',
'generated_post_url' => 'رابط المنشور المُنشأ بالذكاء الاصطناعي',
'variable' => 'متغير سير العمل',
'now' => 'التاريخ والوقت الحالي',
],
'test' => [
'description' => 'يشغّل الأتمتة من البداية إلى النهاية باستخدام حمولة مُشغِّل مُصطنعة. مفيد للتحقق من كل عقدة دون انتظار الجدول أو الخلاصة الحقيقية.',
'starting' => 'جارٍ بدء تشغيل الاختبار…',
'in_progress' => 'قيد التنفيذ',
'completed' => 'مكتمل',
'failed' => 'فشل',
'waiting' => 'في الانتظار',
'close' => 'إغلاق',
'no_node_runs' => 'في انتظار بدء العقدة الأولى…',
'node_input' => 'المدخلات',
'node_output' => 'المخرجات',
'node_error' => 'خطأ',
'no_new_items' => 'لا توجد عناصر جديدة — لم يُشغَّل أي شيء لاحق.',
'error_starting' => 'تعذر بدء تشغيل الاختبار.',
'with_real_data' => 'ببيانات حقيقية',
'run' => 'تشغيل الاختبار',
'idle_hint' => 'اضغط على "تشغيل الاختبار" لتنفيذ الأتمتة من البداية إلى النهاية.',
'real_data_hint' => 'سيقوم هذا الاختبار بنشر المنشورات، وتقديم علامات الاستطلاع، وإطلاق تأثيرات جانبية خارجية.',
'dry_badge' => 'تشغيل تجريبي',
],
'status' => [
'draft' => 'مسودة',
'active' => 'نشط',
'paused' => 'متوقف مؤقتًا',
],
'index' => [
'empty_title' => 'لا توجد عمليات أتمتة بعد',
'empty_description' => 'أنشئ أول أتمتة لك لبدء النشر تلقائيًا.',
'columns' => [
'name' => 'الاسم',
'status' => 'الحالة',
'created' => 'تاريخ الإنشاء',
],
],
'form' => [
'activate_error_fallback' => 'تعذر تفعيل الأتمتة.',
'pause_error_fallback' => 'تعذر إيقاف الأتمتة مؤقتًا.',
'save_error_fallback' => 'تعذر حفظ الأتمتة.',
'save_success' => 'تم حفظ الأتمتة.',
'empty_canvas_title' => 'ابدأ ببناء أتمتتك',
'empty_canvas_description' => 'اسحب عقدة من اللوحة الجانبية للبدء.',
'name_placeholder' => 'أتمتة بلا عنوان',
],
'nodes' => [
'trigger' => 'مُشغّل',
'generate' => 'إنشاء',
'delay' => 'تأخير',
'condition' => 'شرط',
'publish' => 'نشر',
'webhook' => 'Webhook',
'end' => 'إنهاء',
'end_summary' => 'يوقف الأتمتة هنا',
'fetch_rss' => 'جلب RSS',
'http_request' => 'طلب HTTP',
'handles' => [
'items' => 'يحتوي على عناصر',
'no_items' => 'لا عناصر',
],
],
'config' => [
'select_placeholder' => 'اختر…',
'invalid_json' => 'هذا ليس JSON صالحًا بعد.',
'expand_editor' => 'توسيع المحرر',
'minimize_editor' => 'تصغير',
'trigger' => [
'type' => 'نوع المُشغّل',
'types' => [
'schedule' => 'جدولة',
'post_published' => 'عند نشر منشور',
'post_scheduled' => 'عند جدولة منشور',
],
'post_published_hint' => 'يعمل كلما تم نشر أي منشور في مساحة العمل هذه. يصبح المنشور المنشور متاحًا في {{ trigger.post }} للعقد اللاحقة.',
'post_scheduled_hint' => 'يعمل كلما تمت جدولة أي منشور في مساحة العمل هذه. يكون المنشور المجدول متاحًا في {{ trigger.post }}.',
'schedule' => [
'field' => 'فاصل المُشغّل',
'fields' => [
'minutes' => 'دقائق',
'hours' => 'ساعات',
'days' => 'أيام',
'weeks' => 'أسابيع',
'months' => 'أشهر',
],
'minutes_interval' => 'الدقائق بين عمليات التشغيل',
'hours_interval' => 'الساعات بين عمليات التشغيل',
'days_interval' => 'الأيام بين عمليات التشغيل',
'hour' => 'التشغيل عند الساعة',
'minute' => 'التشغيل عند الدقيقة',
'weekdays' => 'التشغيل في أيام الأسبوع',
'day_of_month' => 'يوم من الشهر',
'weekday_names' => [
'sun' => 'أحد',
'mon' => 'اثنين',
'tue' => 'ثلاثاء',
'wed' => 'أربعاء',
'thu' => 'خميس',
'fri' => 'جمعة',
'sat' => 'سبت',
],
'summary' => [
'every_n_minutes' => '{1}يعمل كل دقيقة|{2}يعمل كل دقيقتين|[3,10]يعمل كل :count دقائق|[11,*]يعمل كل :count دقيقة',
'every_n_hours' => '{1}يعمل كل ساعة عند الدقيقة :minute|{2}يعمل كل ساعتين عند الدقيقة :minute|[3,10]يعمل كل :count ساعات عند الدقيقة :minute|[11,*]يعمل كل :count ساعة عند الدقيقة :minute',
'every_n_days' => '{1}يعمل كل يوم عند :time|{2}يعمل كل يومين عند :time|[3,10]يعمل كل :count أيام عند :time|[11,*]يعمل كل :count يوم عند :time',
'weekly' => 'يعمل كل :days عند :time',
'monthly' => 'يعمل في اليوم :day من كل شهر عند :time',
],
],
],
'generate' => [
'social_accounts' => 'الحسابات الاجتماعية',
'social_accounts_empty' => 'لا توجد حسابات اجتماعية متصلة. اربط واحدًا أولًا.',
'target_slide_count' => 'الشرائح المراد إنشاؤها',
'prompt_template' => 'قالب الموجّه',
'prompt_template_hint' => 'اكتب {{ لإدراج بيانات من الخطوات السابقة.',
'image_count' => 'الصور المراد إنشاؤها',
'image_count_hint' => '0 = منشور نصي فقط (بلا صورة). 1 = صورة واحدة. 2 أو أكثر = عرض دائري.',
'use_brand_voice' => 'استخدام صوت العلامة التجارية',
'use_brand_voice_hint' => 'طبّق وصف علامتك التجارية وصوتها. أوقفه للتنسيق الأمين لمصادر الجهات الخارجية (الأخبار، RSS).',
'use_brand_visuals' => 'استخدام العناصر المرئية للعلامة التجارية',
'use_brand_visuals_hint' => 'وجّه صور الذكاء الاصطناعي بألوان علامتك التجارية وهويتها. أوقفه للصور المحايدة المدفوعة بموضوع المنشور فقط.',
'style' => 'النمط',
'account_summary' => '{1}حساب واحد · :format|{2}حسابان · :format|[3,10]:count حسابات · :format|[11,*]:count حساب · :format',
'formats' => [
'single' => 'مفرد',
'carousel' => 'عرض دائري',
],
],
'delay' => [
'duration' => 'المدة',
'unit' => 'الوحدة',
'units' => [
'minutes' => 'دقائق',
'hours' => 'ساعات',
'days' => 'أيام',
],
],
'condition' => [
'field' => 'الحقل',
'operator' => 'العامل',
'operators' => [
'contains' => 'يحتوي على',
'not_contains' => 'لا يحتوي على',
'equals' => 'يساوي',
'not_equals' => 'لا يساوي',
'matches' => 'يطابق (تعبير نمطي)',
'greater_than' => 'أكبر من',
'less_than' => 'أصغر من',
],
'value' => 'القيمة',
],
'publish' => [
'mode' => 'الوضع',
'modes' => [
'now' => 'نشر الآن',
'scheduled' => 'جدولة',
'draft' => 'حفظ كمسودة',
],
'scheduled_offset' => 'الإزاحة عن المُشغّل (بالدقائق)',
'offset_summary' => ':mode · +:offset د',
],
'webhook' => [
'url' => 'الرابط',
'method' => 'الطريقة',
'payload_template' => 'قالب الحمولة (JSON)',
],
'end' => [
'reason' => 'السبب (اختياري)',
'reason_placeholder' => 'مثال: تمت تصفيته بواسطة شرط',
],
'fetch_rss' => [
'feed_url' => 'رابط الخلاصة',
'feed_url_hint' => 'في التشغيل الأول، تُضبط العلامة على "الآن" حتى لا تُغرِق العناصر التاريخية العقد اللاحقة. تشاهد عمليات التشغيل اللاحقة فقط العناصر الأحدث من الاستطلاع السابق.',
'inspect' => 'فحص الخلاصة',
'inspecting' => 'جارٍ الفحص…',
'inspect_hint' => 'اجلب عينة لاكتشاف الحقول المتاحة للاستخدام في العقد اللاحقة.',
'inspect_error' => 'تعذرت قراءة هذه الخلاصة. تحقق من الرابط وحاول مرة أخرى.',
'discovered_fields' => 'الحقول المتاحة',
'discovered_empty' => 'لم يتم العثور على حقول في أحدث عنصر.',
],
'http_request' => [
'url' => 'الرابط',
'method' => 'الطريقة',
'auth_type' => 'المصادقة',
'auth' => [
'none' => 'بلا (عام)',
'bearer' => 'رمز Bearer',
'basic' => 'مصادقة أساسية',
'api_key' => 'ترويسة مفتاح API',
],
'bearer_token' => 'رمز Bearer',
'basic_username' => 'اسم المستخدم',
'basic_password' => 'كلمة المرور',
'api_key_header' => 'اسم الترويسة',
'api_key_value' => 'مفتاح API',
'body_template' => 'قالب النص (JSON)',
'headers' => 'الترويسات',
'header_name' => 'اسم الترويسة',
'header_value' => 'القيمة',
'add_header' => 'إضافة ترويسة',
'polling_section' => 'القائمة وإزالة التكرار (اختياري)',
'polling_hint' => 'عندما تكون الاستجابة قائمة، يشغّل كل عنصر سير العمل بشكل منفصل. يشغّل الكائن الواحد مرة واحدة.',
'items_path' => 'مسار العناصر',
'items_path_hint' => 'اتركه فارغًا إذا كانت الاستجابة مصفوفة بالفعل. استخدم مسارًا منقّطًا (مثل data.items) لمصفوفة متداخلة، أو * لكائن مفهرس بالمعرّف.',
'item_key_path' => 'مسار مفتاح العنصر',
'item_key_path_hint' => 'مسار JSON لمعرّف فريد (مثل id). تُتخطى العناصر التي سبقت رؤيتها، لذا تظل الخلاصة بلا تواريخ تُمرّر الإدخالات الجديدة فقط.',
'item_date_path' => 'مسار تاريخ العنصر',
'item_date_path_hint' => 'مسار JSON للطابع الزمني للعنصر (مثل published_at). يُفضَّل على مسار المفتاح عند توفره. يسجّل الاستطلاع الأول الأساس ولا يمرّر شيئًا، لذا لا تُغرِق الخلاصة الموجودة في اليوم الأول.',
],
],
'delete' => [
'title' => 'حذف الأتمتة',
'description' => 'هل أنت متأكد من رغبتك في حذف هذه الأتمتة؟ ستتم إزالة جميع عمليات التشغيل وعناصر التشغيل أيضًا. لا يمكن التراجع عن هذا الإجراء.',
'confirm' => 'حذف',
'cancel' => 'إلغاء',
],
'flash' => [
'deleted' => 'تم حذف الأتمتة بنجاح!',
],
'errors' => [
'no_active_social_accounts' => 'لا توجد حسابات اجتماعية نشطة مُهيّأة لهذه الأتمتة.',
'must_have_one_trigger' => 'يجب أن تحتوي الأتمتة على عقدة مُشغّل واحدة بالضبط.',
'trigger_must_be_connected' => 'يجب أن تكون عقدة المُشغّل متصلة بعقدة واحدة على الأقل.',
'graph_contains_cycle' => 'يحتوي مخطط الأتمتة على حلقة.',
'only_failed_can_retry' => 'يمكن إعادة محاولة عمليات التشغيل الفاشلة فقط.',
'no_generated_post' => 'لم يتم العثور على منشور مُنشأ في التشغيل.',
'webhook_server_error' => 'خطأ في خادم Webhook.',
'webhook_request_failed' => 'تعذر إكمال طلب Webhook.',
'webhook_missing_url' => 'عقدة Webhook تفتقد إلى رابط.',
'webhook_invalid_payload_json' => 'قالب الحمولة ليس JSON صالحًا.',
'url_not_allowed' => 'رابط الطلب يشير إلى عنوان خاص أو غير قابل للوصول وتم حظره.',
'node_no_longer_exists' => 'العقدة :node_id لم تعد موجودة في الأتمتة.',
'no_trigger_connection' => 'لا توجد عقدة متصلة بعقدة المُشغّل.',
'fetch_rss_missing_url' => 'عقدة جلب RSS تفتقد إلى رابط خلاصة.',
'fetch_rss_request_failed' => 'فشل طلب خلاصة RSS.',
'fetch_rss_malformed' => 'خلاصة RSS مشوّهة.',
'http_missing_url' => 'عقدة طلب HTTP تفتقد إلى رابط.',
'http_request_exception' => 'أطلق طلب HTTP استثناءً.',
'http_request_failed' => 'فشل طلب HTTP.',
'http_items_path_not_array' => 'لم يُفضِ مسار العناصر إلى قائمة.',
],
];

76
lang/ar/billing.php Normal file
View file

@ -0,0 +1,76 @@
<?php
return [
'title' => 'الفوترة',
'past_due_notice' => [
'title' => 'دفعة متأخرة',
'description' => 'حدّث طريقة الدفع للحفاظ على اشتراكك نشطًا.',
'cta' => 'تحديث الدفع',
],
'annual_banner' => [
'title' => 'احصل على شهرين مجانًا',
'description' => 'انتقل إلى الفوترة السنوية وادفع أقل كل شهر — الخطة نفسها، لا شيء آخر يتغير.',
'cta' => 'الترقية إلى السنوية',
],
'subscribe' => [
'billed_monthly' => 'فوترة شهرية',
'billed_yearly' => 'فوترة سنوية',
'prices' => [
'workspace' => ['monthly' => '$12', 'yearly_per_month' => '$10', 'yearly' => '$120'],
],
],
'plan' => [
'title' => 'الخطة',
'description' => 'إدارة خطة اشتراكك.',
'label' => 'الخطة',
'workspaces' => '{1}مساحة عمل واحدة|{2}مساحتا عمل|[3,10]:count مساحات عمل|[11,*]:count مساحة عمل',
'per_workspace' => 'لكل مساحة عمل',
'price' => 'السعر',
'month' => 'شهر',
'trial' => 'تجريبي',
'active' => 'نشط',
'past_due' => 'متأخر',
'cancelling' => 'قيد الإلغاء',
'trial_ends' => 'تنتهي الفترة التجريبية',
],
'subscription' => [
'title' => 'الاشتراك',
'description' => 'إدارة طريقة الدفع وتفاصيل الفوترة والاشتراك.',
'payment_method' => 'طريقة الدفع',
'no_payment_method' => 'لا توجد طريقة دفع مسجّلة بعد.',
'expires_on' => 'تنتهي في :month/:year',
'manage_label' => 'الاشتراك',
'manage_stripe' => 'الإدارة عبر Stripe',
],
'invoices' => [
'title' => 'الفواتير',
'description' => 'نزّل فواتيرك السابقة.',
'empty' => 'لم يتم العثور على فواتير',
'paid' => 'مدفوعة',
],
'flash' => [
'plan_changed' => 'أنت الآن على خطة :plan.',
'switched_to_yearly' => 'أنت الآن على الفوترة السنوية.',
'cannot_manage' => 'يمكن لمالك الحساب فقط إدارة الفوترة.',
'credits_exhausted' => 'نفد رصيد الذكاء الاصطناعي — تم استخدام مخصصك الشهري البالغ :limit. رقِّ خطتك أو انتظر حتى الشهر المقبل.',
'subscription_required' => 'يلزم وجود اشتراك نشط لاستخدام ميزات الذكاء الاصطناعي.',
],
'processing' => [
'page_title' => 'جارٍ المعالجة...',
'title' => 'جارٍ معالجة اشتراكك',
'description' => 'يرجى الانتظار بينما نُعِدّ حسابك. لن يستغرق هذا سوى لحظة.',
'success_title' => 'كل شيء جاهز!',
'success_description' => 'اشتراكك نشط. جارٍ إعادة توجيهك إلى مساحات عملك...',
'cancelled_title' => 'تم إلغاء الدفع',
'cancelled_description' => 'تم إلغاء عملية الدفع. لم تُجرَ أي رسوم.',
'retry' => 'إعادة المحاولة',
],
];

39
lang/ar/brands.php Normal file
View file

@ -0,0 +1,39 @@
<?php
return [
'new_brand' => 'علامة تجارية جديدة',
'no_brands_yet' => 'لا توجد علامات تجارية بعد',
'no_brands_description' => 'أنشئ علامات تجارية لتنظيم حساباتك الاجتماعية حسب العميل أو المشروع',
'accounts_count' => ':count حساب',
'create' => [
'title' => 'إنشاء علامة تجارية',
'description' => 'امنح علامتك التجارية اسمًا لتجميع الحسابات الاجتماعية',
'name' => 'اسم العلامة التجارية',
'name_placeholder' => 'مثال: Acme Corp، شخصي',
'submit' => 'إنشاء علامة تجارية',
'submitting' => 'جارٍ الإنشاء...',
],
'edit' => [
'title' => 'تعديل العلامة التجارية',
'description' => 'تحديث اسم هذه العلامة التجارية',
'name' => 'اسم العلامة التجارية',
'name_placeholder' => 'مثال: Acme Corp، شخصي',
'submit' => 'حفظ التغييرات',
'submitting' => 'جارٍ الحفظ...',
],
'delete' => [
'title' => 'حذف العلامة التجارية',
'description' => 'هل أنت متأكد من رغبتك في حذف هذه العلامة التجارية؟ سيتم إلغاء تعيين الحسابات الاجتماعية دون حذفها.',
'confirm' => 'حذف',
'cancel' => 'إلغاء',
],
'flash' => [
'created' => 'تم إنشاء العلامة التجارية بنجاح!',
'updated' => 'تم تحديث العلامة التجارية بنجاح!',
'deleted' => 'تم حذف العلامة التجارية بنجاح!',
],
];

12
lang/ar/calendar.php Normal file
View file

@ -0,0 +1,12 @@
<?php
return [
'title' => 'التقويم',
'today' => 'اليوم',
'day' => 'يوم',
'week' => 'أسبوع',
'month' => 'شهر',
'new_post' => 'منشور جديد',
'no_content' => 'لا يوجد محتوى',
'more' => '+:count المزيد',
];

18
lang/ar/comments.php Normal file
View file

@ -0,0 +1,18 @@
<?php
return [
'placeholder' => 'اكتب تعليقًا...',
'reply_placeholder' => 'اكتب ردًا...',
'reply' => 'رد',
'edit' => 'تعديل',
'delete' => 'حذف',
'edited' => 'تم التعديل',
'save' => 'حفظ',
'cancel' => 'إلغاء',
'send' => 'إرسال',
'replying_to' => 'رد على :name',
'empty' => 'لا توجد تعليقات بعد. ابدأ المحادثة.',
'load_more' => 'تحميل التعليقات الأقدم',
'today' => 'اليوم',
'yesterday' => 'أمس',
];

67
lang/ar/common.php Normal file
View file

@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
return [
'back' => 'رجوع',
'beta' => 'تجريبي',
'confirm_modal' => [
'cannot_be_undone' => 'لا يمكن التراجع عن هذا الإجراء.',
'type' => 'اكتب',
'to_confirm' => 'للتأكيد.',
'copy_to_clipboard' => 'نسخ إلى الحافظة',
'delete_keyword' => 'حذف',
],
'photo_upload' => [
'upload' => 'رفع',
'uploading' => 'جارٍ الرفع...',
'remove' => 'إزالة الصورة',
'hint' => 'موصى به: صورة مربعة، بحد أقصى 2 ميغابايت.',
'crop_title' => 'قص الصورة',
'crop_description' => 'حدّد المنطقة التي تريد الاحتفاظ بها.',
'crop_hint' => 'اسحب للتحريك، أو زاوية لتغيير الحجم.',
'crop_save' => 'حفظ',
'crop_cancel' => 'إلغاء',
'crop_error' => 'تعذّر تحميل هذه الصورة. جرّب ملفًا آخر.',
],
'timezone' => [
'select' => 'اختر المنطقة الزمنية',
'search' => 'البحث عن منطقة زمنية...',
'empty' => 'لم يتم العثور على منطقة زمنية',
],
'date_picker' => [
'select' => 'اختر التاريخ',
],
'date_range_picker' => [
'placeholder' => 'اختر نطاقًا زمنيًا',
'today' => 'اليوم',
'yesterday' => 'أمس',
'last_7_days' => 'آخر 7 أيام',
'last_30_days' => 'آخر 30 يومًا',
'last_3_months' => 'آخر 3 أشهر',
'last_6_months' => 'آخر 6 أشهر',
'last_12_months' => 'آخر 12 شهرًا',
'this_month' => 'هذا الشهر',
'last_month' => 'الشهر الماضي',
'year_to_date' => 'منذ بداية العام',
'last_year' => 'العام الماضي',
],
'cancel' => 'إلغاء',
'clear' => 'مسح',
'close' => 'إغلاق',
'loading_more' => 'جارٍ تحميل المزيد...',
'actions' => [
'copy' => 'نسخ',
'copied' => 'تم النسخ',
'copy_failed' => 'فشل النسخ إلى الحافظة',
],
];

54
lang/ar/labels.php Normal file
View file

@ -0,0 +1,54 @@
<?php
return [
'title' => 'التسميات',
'description' => 'أنشئ تسميات لتنظيم منشوراتك وتصنيفها',
'search' => 'البحث في التسميات...',
'new_label' => 'تسمية جديدة',
'no_labels_yet' => 'لا توجد تسميات بعد',
'no_search_results' => 'لا توجد تسميات مطابقة لبحثك',
'try_different_search' => 'جرّب كلمة مختلفة أو امسح البحث.',
'create_first_label' => 'أنشئ أول تسمية لك',
'table' => [
'name' => 'الاسم',
'created_at' => 'تاريخ الإنشاء',
],
'actions' => [
'edit' => 'تعديل التسمية',
'delete' => 'حذف التسمية',
],
'create' => [
'title' => 'إنشاء تسمية',
'description' => 'امنح تسميتك اسمًا واختر لونًا',
'name' => 'الاسم',
'name_placeholder' => 'أدخل اسم التسمية...',
'color' => 'اللون',
'submit' => 'إنشاء تسمية',
'submitting' => 'جارٍ الإنشاء...',
],
'edit' => [
'title' => 'تعديل التسمية',
'description' => 'تحديث اسم ولون هذه التسمية',
'name' => 'الاسم',
'name_placeholder' => 'أدخل اسم التسمية...',
'color' => 'اللون',
'submit' => 'حفظ التغييرات',
'submitting' => 'جارٍ الحفظ...',
],
'delete' => [
'title' => 'حذف التسمية',
'description' => 'هل أنت متأكد من رغبتك في حذف هذه التسمية؟ لا يمكن التراجع عن هذا الإجراء.',
'confirm' => 'حذف',
'cancel' => 'إلغاء',
],
'flash' => [
'created' => 'تم إنشاء التسمية بنجاح!',
'updated' => 'تم تحديث التسمية بنجاح!',
'deleted' => 'تم حذف التسمية بنجاح!',
],
];

22
lang/ar/mail.php Normal file
View file

@ -0,0 +1,22 @@
<?php
return [
'mentioned' => [
'subject' => 'أشار إليك :name على TryPost',
'title' => 'أشار إليك :name',
'intro' => 'أشار إليك :name في تعليق على منشور.',
'cta' => 'عرض التعليق',
],
'workspace_connections_disconnected' => [
'subject' => '{1} حساب واحد يحتاج إلى إعادة الربط في :workspace|{2} حسابان يحتاجان إلى إعادة الربط في :workspace|[3,10] :count حسابات تحتاج إلى إعادة الربط في :workspace|[11,*] :count حسابًا يحتاج إلى إعادة الربط في :workspace',
'title' => 'حسابات تحتاج إلى إعادة الربط',
'intro' => 'تم فصل الحسابات الاجتماعية التالية في مساحة العمل <strong>:workspace</strong> وتحتاج إلى إعادة الربط:',
'reasons_title' => 'قد يكون هذا قد حدث بسبب:',
'reason_expired' => 'انتهاء صلاحية رموز الوصول',
'reason_revoked' => 'قيامك بإلغاء وصول TryPost على المنصة',
'reason_changed' => 'قيام المنصة بتغيير متطلبات المصادقة الخاصة بها',
'reconnect_cta' => 'يرجى إعادة ربط هذه الحسابات لمواصلة جدولة المنشورات ونشرها.',
'button' => 'إعادة ربط الحسابات',
],
];

18
lang/ar/notifications.php Normal file
View file

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
return [
'post_ready' => [
'title' => 'منشورك جاهز',
'body' => 'أنهى الذكاء الاصطناعي عمله للتو. انقر للمراجعة والنشر.',
],
'account_disconnected' => [
'title' => 'تم فصل حساب :platform',
'body' => 'يحتاج :account إلى إعادة الربط',
],
'account_token_expired' => [
'title' => 'يحتاج حساب :platform إلى إعادة الربط',
'body' => 'انتهت جلسة :account — يرجى إعادة الربط لمواصلة النشر',
],
];

41
lang/ar/onboarding.php Normal file
View file

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
return [
'title' => 'مرحبًا بك في TryPost',
'description' => 'أخبرنا بما يصفك أنت أو نشاطك التجاري على أفضل وجه حتى نتمكن من تخصيص تجربتك.',
'continue' => 'متابعة',
'personas' => [
'creator' => 'صانع محتوى',
'freelancer' => 'مستقل',
'developer' => 'مطوّر',
'startup' => 'شركة ناشئة',
'agency' => 'وكالة',
'small_business' => 'نشاط تجاري صغير',
'marketer' => 'مسوّق',
'online_store' => 'متجر إلكتروني',
'other' => 'أخرى',
],
'goals_title' => 'ما هدفك من استخدام TryPost؟',
'goals_description' => 'اختر كل ما يناسبك وسنقوم بإعداد TryPost من أجلك.',
'goals' => [
'save_time' => 'توفير الوقت بالنشر في كل مكان دفعة واحدة',
'ai_content' => 'إنشاء منشورات أسرع بالذكاء الاصطناعي',
'plan_calendar' => 'التخطيط لمنشوراتي على التقويم',
'stay_on_brand' => 'الحفاظ على اتساق كل منشور مع العلامة التجارية',
'grow_audience' => 'تنمية جمهوري وزيادة التفاعل',
'drive_sales' => 'الحصول على المزيد من الزيارات والمبيعات',
'manage_clients' => 'إدارة عدة علامات تجارية أو عملاء',
'team_collaboration' => 'العمل مع فريقي',
'automate_api' => 'أتمتة النشر عبر الواجهة البرمجية أو MCP أو الكود',
'track_performance' => 'معرفة أداء منشوراتي',
'just_exploring' => 'مجرد استكشاف في الوقت الحالي',
'other' => 'شيء آخر',
],
'connect' => [
'title' => 'اربط شبكتك الأولى',
'description' => 'اربط حسابًا اجتماعيًا واحدًا على الأقل لبدء الجدولة. يمكنك إضافة المزيد في أي وقت.',
'must_connect' => 'اربط شبكة واحدة على الأقل للمتابعة.',
],
];

19
lang/ar/pagination.php Normal file
View file

@ -0,0 +1,19 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Pagination Language Lines
|--------------------------------------------------------------------------
|
| The following language lines are used by the paginator library to build
| the simple pagination links. You are free to change them to anything
| you want to customize your views to better match your application.
|
*/
'previous' => '&laquo; السابق',
'next' => 'التالي &raquo;',
];

22
lang/ar/passwords.php Normal file
View file

@ -0,0 +1,22 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Password Reset Language Lines
|--------------------------------------------------------------------------
|
| The following language lines are the default lines which match reasons
| that are given by the password broker for a password update attempt
| outcome such as failure due to an invalid password / reset token.
|
*/
'reset' => 'تمت إعادة تعيين كلمة المرور الخاصة بك.',
'sent' => 'لقد أرسلنا رابط إعادة تعيين كلمة المرور إلى بريدك الإلكتروني.',
'throttled' => 'يرجى الانتظار قبل المحاولة مرة أخرى.',
'token' => 'رمز إعادة تعيين كلمة المرور هذا غير صالح.',
'user' => 'لا يمكننا العثور على مستخدم بهذا البريد الإلكتروني.',
];

679
lang/ar/posts.php Normal file
View file

@ -0,0 +1,679 @@
<?php
return [
'title' => 'المنشورات',
'search' => 'البحث في المنشورات...',
'all_posts' => 'جميع المنشورات',
'new_post' => 'منشور جديد',
'no_posts' => 'لم يتم العثور على منشورات',
'no_search_results' => 'لا توجد منشورات مطابقة لبحثك',
'try_different_search' => 'جرّب كلمة مختلفة أو امسح البحث.',
'start_creating' => 'ابدأ بإنشاء أول منشور لك.',
'filter_by_label' => 'التصفية حسب التسمية',
'label_search_placeholder' => 'البحث في التسميات...',
'no_labels' => 'لم يتم العثور على تسميات.',
'clear_label_filter' => 'مسح تصفية التسميات',
'table' => [
'post' => 'المنشور',
'status' => 'الحالة',
'content' => 'المحتوى',
'platforms' => 'المنصات',
'labels' => 'التسميات',
'scheduled_at' => 'التاريخ',
'actions' => '',
],
'manage_posts' => 'إدارة جميع منشوراتك',
'delete_confirm' => 'هل أنت متأكد من رغبتك في حذف هذا المنشور؟',
'by' => 'بواسطة',
'actions' => [
'view' => 'عرض المنشور',
'delete' => 'حذف',
'duplicate' => 'تكرار',
'copy_id' => 'نسخ المعرّف',
'copied' => 'تم نسخ المعرّف إلى الحافظة',
],
'form' => [
'post_type' => 'نوع المنشور',
'board' => 'اللوحة',
'select_board' => 'اختر لوحة',
'search_board' => 'البحث عن لوحة...',
'no_board_found' => 'لم يتم العثور على لوحة',
'media' => 'الوسائط',
'min' => 'الحد الأدنى',
'uploading' => 'جارٍ الرفع...',
'drop_to_upload' => 'أفلِت للرفع',
'drag_and_drop' => 'اسحب وأفلِت أو انقر للرفع',
'photos_and_videos' => 'الصور ومقاطع الفيديو',
'photos_only' => 'الصور فقط',
'videos_only' => 'مقاطع الفيديو فقط',
'drag_to_reorder' => 'اسحب لإعادة الترتيب',
'caption' => 'التسمية التوضيحية',
'write_caption' => 'اكتب التسمية التوضيحية...',
'content_exceeds_platform' => ':platform: أطول بمقدار :over حرفًا (الحد الأقصى :limit).',
'tiktok' => [
'settings' => 'إعدادات TikTok',
'variant_label' => 'نوع المنشور',
'variant' => [
'video' => 'فيديو',
'photo' => 'عرض صور دائري',
],
'posting_to' => 'النشر إلى',
'privacy_level' => 'من يمكنه رؤية هذا الفيديو؟',
'privacy_placeholder' => 'اختر من يمكنه مشاهدة هذا المنشور',
'privacy' => [
'public' => 'عام للجميع',
'friends' => 'الأصدقاء المتابَعون بشكل متبادل',
'followers' => 'المتابِعون',
'private' => 'أنا فقط',
'private_disabled_branded' => 'لا يمكن ضبط ظهور المحتوى ذي العلامة التجارية على خاص.',
],
'privacy_hint' => 'تعتمد الخيارات المتاحة على إعدادات حسابك على TikTok.',
'auto_add_music' => 'إضافة موسيقى تلقائيًا',
'auto_add_music_hint' => 'تتوفر هذه الميزة للصور فقط. ستضيف موسيقى افتراضية يمكنك تغييرها لاحقًا.',
'yes' => 'نعم',
'no' => 'لا',
'allow_users' => 'السماح للمستخدمين بـ:',
'comments' => 'التعليق',
'duet' => 'الديو (Duet)',
'stitch' => 'الدمج (Stitch)',
'is_aigc' => 'فيديو مُنشأ بالذكاء الاصطناعي',
'disclose' => 'الإفصاح عن محتوى الفيديو',
'disclose_hint' => 'فعّله للإفصاح عن أن هذا الفيديو يروّج لسلع أو خدمات مقابل شيء ذي قيمة. قد يروّج الفيديو لك أو لطرف ثالث أو كليهما.',
'promotional_organic_title' => 'ستُصنَّف صورتك/فيديوك على أنها "محتوى ترويجي".',
'promotional_paid_title' => 'ستُصنَّف صورتك/فيديوك على أنها "شراكة مدفوعة".',
'promotional_description' => 'لا يمكن تغيير هذا بمجرد نشر الفيديو.',
'compliance_incomplete' => 'يجب أن تحدد ما إذا كان محتواك يروّج لك أو لطرف ثالث أو كليهما.',
'privacy_required' => 'مستوى الخصوصية على TikTok مطلوب عند النشر.',
'branded_cleared_private' => 'تم مسح الخصوصية لأن المحتوى ذا العلامة التجارية لا يمكن أن يكون خاصًا.',
'interaction_disabled_by_creator' => 'مُعطَّل في إعدادات حسابك على TikTok',
'max_duration_exceeded' => 'مدة الفيديو :duration ث لكن هذا الحساب يمكنه نشر مقاطع فيديو حتى :max ث فقط.',
'processing_hint' => 'بعد النشر، قد يستغرق معالجة المحتوى وظهوره على ملفك الشخصي في TikTok بضع دقائق.',
'brand_organic' => 'علامتك التجارية',
'brand_organic_hint' => 'أنت تروّج لنفسك أو لعلامتك التجارية الخاصة. سيُصنَّف هذا الفيديو على أنه محتوى عضوي للعلامة التجارية.',
'brand_content' => 'محتوى ذو علامة تجارية',
'brand_content_hint' => 'أنت تروّج لعلامة تجارية أخرى أو لطرف ثالث. سيُصنَّف هذا الفيديو على أنه محتوى ذو علامة تجارية.',
'compliance' => [
'agree' => 'بالنشر، فإنك توافق على',
'music_usage' => 'تأكيد استخدام الموسيقى',
'and' => 'و',
'branded_policy' => 'سياسة المحتوى ذي العلامة التجارية',
],
],
'instagram' => [
'settings' => 'إعدادات Instagram',
'posting_to' => 'النشر إلى',
'variant_label' => 'نوع المنشور',
'variant' => [
'feed' => 'منشور',
'reel' => 'ريل',
'story' => 'قصة',
],
'aspect_label' => 'نسبة العرض إلى الارتفاع',
'aspect' => [
'square' => 'مربع (1:1)',
'portrait' => 'عمودي (4:5)',
'landscape' => 'أفقي (16:9)',
'original' => 'الأصلي',
],
],
'facebook' => [
'settings' => 'إعدادات Facebook',
'posting_to' => 'النشر إلى',
'variant_label' => 'نوع المنشور',
'variant' => [
'post' => 'منشور',
'reel' => 'ريل',
'story' => 'قصة',
],
'aspect_label' => 'نسبة العرض إلى الارتفاع',
'aspect' => [
'square' => 'مربع (1:1)',
'portrait' => 'عمودي (4:5)',
'landscape' => 'أفقي (16:9)',
'original' => 'الأصلي',
],
],
'linkedin' => [
'settings' => 'إعدادات LinkedIn',
'settings_page' => 'إعدادات صفحة LinkedIn',
'posting_to' => 'النشر إلى',
'document_title' => 'عنوان المستند',
'document_title_placeholder' => 'يظهر على منشور مستند PDF الخاص بك',
],
'pinterest' => [
'settings' => 'إعدادات Pinterest',
'posting_to' => 'النشر إلى',
'variant_label' => 'نوع الدبوس',
'variant' => [
'pin' => 'دبوس',
'video_pin' => 'دبوس فيديو',
'carousel' => 'عرض دائري',
],
'board' => 'اللوحة',
'select_board' => 'اختر لوحة',
'no_boards' => 'لم يتم العثور على لوحات Pinterest. أنشئ واحدة في حسابك على Pinterest أولًا.',
'search_board' => 'البحث في اللوحات...',
'no_board_found' => 'لا توجد لوحة مطابقة لبحثك.',
'board_required' => 'اختر لوحة Pinterest لنشر هذا المنشور.',
],
'discord' => [
'settings' => 'إعدادات Discord',
'posting_to' => 'النشر إلى',
'channel' => 'القناة',
'select_channel' => 'اختر قناة',
'loading_channels' => 'جارٍ تحميل القنوات…',
'search_channel' => 'البحث في القنوات…',
'no_channels' => 'لم يتم العثور على قنوات.',
'channel_required' => 'اختر قناة Discord لنشر هذا المنشور.',
'mentions' => 'الإشارات',
'search_mention' => 'أشِر إلى دور أو عضو…',
'embeds' => 'التضمينات',
'embed' => 'تضمين',
'add_embed' => 'إضافة تضمين',
'embed_title' => 'عنوان التضمين',
'embed_description' => 'وصف التضمين',
'embed_url' => 'رابط التضمين',
'embed_image' => 'رابط الصورة',
'embed_color' => 'اللون',
],
'warnings' => [
'no_variant' => 'اختر نوع منشور للمتابعة.',
'requires_media' => 'يتطلب هذا النوع من المنشورات صورة أو فيديو واحدًا على الأقل.',
'max_files_exceeded' => 'يقبل هذا النوع من المنشورات حتى :max ملف وسائط (لديك :current).',
'min_files_required' => 'يتطلب هذا النوع من المنشورات :min ملف وسائط على الأقل (لديك :current).',
'no_video_allowed' => 'لا يقبل هذا النوع من المنشورات مقاطع الفيديو.',
'no_image_allowed' => 'يقبل هذا النوع من المنشورات مقاطع الفيديو فقط.',
'no_document_allowed' => 'لا يقبل هذا النوع من المنشورات مستندات PDF.',
'no_mixed_media' => 'لا يمكن الجمع بين الصور والفيديو في نفس المنشور.',
'document_not_alone' => 'يجب نشر ملف PDF بمفرده، دون صور أو مقاطع فيديو أخرى.',
'gif_not_allowed' => 'لا تقبل هذه المنصة صور GIF. أزِل صورة GIF أو اختر شبكة مختلفة.',
'image_too_large' => 'تتجاوز الصورة حد :max لهذا النوع من المنشورات (حجم صورتك :current).',
'video_too_large' => 'يتجاوز الفيديو حد :max لهذا النوع من المنشورات (حجم فيديوك :current).',
'document_too_large' => 'يتجاوز ملف PDF حد :max لهذا النوع من المنشورات (حجم ملفك :current).',
'video_too_long' => 'مدة الفيديو :current، لكن هذا النوع من المنشورات يسمح حتى :max.',
'aspect_ratio_too_narrow' => 'نسبة العرض إلى الارتفاع :current طويلة جدًا لهذا النوع من المنشورات (الحد الأدنى :min).',
'aspect_ratio_too_wide' => 'نسبة العرض إلى الارتفاع :current عريضة جدًا لهذا النوع من المنشورات (الحد الأقصى :max).',
],
],
'status' => [
'pending' => 'قيد الانتظار',
'draft' => 'مسودة',
'scheduled' => 'مجدول',
'publishing' => 'قيد النشر',
'retrying' => 'إعادة المحاولة',
'published' => 'منشور',
'partially_published' => 'منشور جزئيًا',
'failed' => 'فشل',
],
'descriptions' => [
'draft' => 'منشورات تنتظر الجدولة',
'scheduled' => 'منشورات مجدولة للنشر',
'published' => 'منشورات تم نشرها بالفعل',
],
'ai' => [
'generate' => [
'button_tooltip' => 'الإنشاء بالذكاء الاصطناعي',
'title' => 'إنشاء منشور بالذكاء الاصطناعي',
'description' => 'صِف موضوع المنشور. سيستخدم الذكاء الاصطناعي سياق علامتك التجارية لكتابته.',
'prompt_label' => 'ما موضوع هذا المنشور؟',
'prompt_placeholder' => 'مثال: الإعلان عن ميزتنا الجديدة لإنشاء الصور للعروض الدائرية',
'preview_label' => 'معاينة',
'start' => 'إنشاء',
'apply' => 'استخدام هذا المحتوى',
'retry' => 'إعادة المحاولة',
'cancel' => 'إلغاء',
],
'review' => [
'button_tooltip' => 'المراجعة بالذكاء الاصطناعي',
'title' => 'مراجعة المنشور بالذكاء الاصطناعي',
'description' => 'يفحص الذكاء الاصطناعي القواعد والإملاء والوضوح. طبّق الاقتراحات التي توافق عليها.',
'loading' => 'جارٍ مراجعة نصك...',
'no_issues' => 'لم يتم العثور على مشكلات. يبدو جيدًا.',
'original' => 'الأصل',
'suggestion' => 'اقتراح',
'apply' => 'تطبيق',
'apply_all' => 'تطبيق الكل',
'applied' => 'تم التطبيق',
'cancel' => 'إلغاء',
],
'image_regenerate' => [
'button' => 'تعديل',
'title' => 'تعديل صورة الذكاء الاصطناعي',
'description' => 'صِف التصحيح. تحل الصورة الجديدة محل الحالية وتحتفظ بموضعها في العرض الدائري.',
'instruction_label' => 'التعليمات',
'instruction_placeholder' => 'مثال: صحّح الخطأ المطبعي في العنوان واجعل الخلفية أفتح.',
'processing' => 'جارٍ إعادة إنشاء الصورة... قد يستغرق هذا بضع ثوانٍ.',
'submit' => 'إعادة إنشاء الصورة',
'cancel' => 'إلغاء',
'success' => 'تم تحديث الصورة. حلّت النسخة الجديدة محل السابقة في منشورك.',
'fallback_title' => 'تحسين نص هذه الصورة',
'errors' => [
'required' => 'التعليمات مطلوبة.',
'start_failed' => 'فشل بدء إعادة الإنشاء.',
'unavailable' => 'تعذر إعادة إنشاء هذه الصورة الآن.',
'timeout' => 'تستغرق إعادة الإنشاء وقتًا أطول من المتوقع. حاول مرة أخرى بعد لحظة.',
'media_not_found' => 'لم يتم العثور على عنصر الوسائط.',
'not_ai_media' => 'يمكن إعادة إنشاء الوسائط المُنشأة بالذكاء الاصطناعي فقط.',
],
],
'templates' => [
'image_card' => [
'name' => 'منشور صورة',
'description' => 'صورة بالذكاء الاصطناعي مع عنوان وتسمية توضيحية.',
],
'carousel' => [
'name' => 'عرض دائري',
'description' => 'عرض دائري متعدد الشرائح مع تسمية توضيحية.',
],
'tweet_card' => [
'name' => 'بطاقة تغريدة',
'description' => 'منشورك بتنسيق منشور X/Twitter.',
],
'tweet_card_image' => [
'name' => 'بطاقة تغريدة مع صورة',
'description' => 'منشورك كبطاقة X/Twitter فوق صورة ضبابية.',
],
],
],
'show' => [
'title' => 'تفاصيل المنشور',
'edit' => 'تعديل',
'back' => 'رجوع',
'no_content' => 'لا توجد تسمية توضيحية',
'platforms' => 'المنصات',
'no_platforms' => 'لم يتم اختيار أي منصات.',
'view_on_platform' => 'العرض على المنصة',
'published_on' => 'نُشر في :date',
'scheduled_for' => 'مجدول لـ :date',
'draft' => 'مسودة',
'status_pending' => 'قيد الانتظار',
'metrics' => 'المقاييس',
'metrics_loading' => 'جارٍ تحميل المقاييس…',
'metrics_unavailable' => 'المقاييس غير متاحة لهذه المنصة بعد.',
'metrics_empty' => 'لم تُرجَع أي مقاييس.',
],
'edit' => [
'title' => 'تعديل المنشور',
'view_title' => 'عرض المنشور',
'labels' => 'التسميات',
'no_labels' => 'لم يتم إنشاء تسميات بعد',
'schedule' => 'جدولة',
'pick_time' => 'اختر الوقت',
'pick_time_past' => 'اختر تاريخًا ووقتًا مستقبليًا.',
'post_now' => 'انشر الآن',
'time' => 'الوقت',
'cancel' => 'إلغاء',
'delete' => 'حذف',
'schedule_for' => 'جدولة لـ',
'schedule_date' => 'تاريخ الجدولة',
'unschedule' => 'إلغاء الجدولة',
'saving' => 'جارٍ الحفظ...',
'saved' => 'تم الحفظ',
'draft' => 'مسودة',
'media' => 'الوسائط',
'add_media' => 'إضافة وسائط',
'caption' => 'التسمية التوضيحية',
'caption_placeholder' => 'اكتب التسمية التوضيحية...',
'compose_title' => 'إنشاء منشور',
'compose_subtitle' => 'اكتب رسالتك وأضِف الوسائط',
'preview_empty' => [
'title' => 'لم يتم اختيار منصة',
'description' => 'اختر منصة للنشر لرؤية المعاينة.',
],
'drop_zone_title' => 'إضافة وسائط',
'drop_zone_subtitle' => 'اسحب الملفات وأفلتها أو انقر للتصفح',
'add' => 'إضافة',
'publish_to' => 'النشر إلى',
'organize' => 'تنظيم',
'signatures' => 'التوقيعات',
'view_on_platform' => 'العرض على المنصة',
'platform_status' => 'حالة المنصة',
'compliance_incomplete' => 'بعض إعدادات المنصة غير مكتملة أو غير متوافقة مع الوسائط المرفقة.',
'compliance' => [
'requires_content_or_media' => 'أضِف نصًا أو وسائط للنشر.',
'requires_media' => 'أضِف صورة أو فيديو للنشر هنا.',
'too_many_files' => 'مسموح بـ :max ملف فقط لهذا التنسيق.',
'too_few_files' => 'أضِف :min ملف على الأقل لهذا التنسيق.',
'no_videos' => 'يُسمح بالصور فقط لهذا التنسيق.',
'no_images' => 'يُسمح بمقاطع الفيديو فقط لهذا التنسيق.',
'no_mixed_media' => 'لا يمكن الجمع بين الصور ومقاطع الفيديو في نفس المنشور.',
'no_gifs' => 'صور GIF غير مدعومة هنا.',
'no_documents' => 'مستندات PDF غير مدعومة في هذا التنسيق.',
'document_not_alone' => 'يجب نشر ملف PDF بمفرده، دون صور أو مقاطع فيديو أخرى.',
'video_too_large' => 'يتجاوز الفيديو حد الحجم لهذه المنصة.',
'video_too_long' => 'يجب أن تكون مدة الفيديو أقل من :seconds ثانية لهذا التنسيق.',
'image_too_large' => 'تتجاوز الصورة حد الحجم لهذه المنصة.',
'document_too_large' => 'يتجاوز ملف PDF حد الحجم لهذه المنصة.',
'aspect_ratio_invalid' => 'نسبة العرض إلى الارتفاع غير مدعومة في هذا التنسيق.',
'no_content_type' => 'اختر نوع محتوى لهذه المنصة.',
'requires_text' => 'أضِف نصًا — يتطلب هذا التنسيق عنوانًا.',
],
'publishing' => 'جارٍ النشر...',
'publishing_overlay_title' => 'جارٍ نشر منشورك',
'publishing_overlay_subtitle' => 'قد يستغرق هذا بضع لحظات. يمكنك مغادرة هذه الصفحة بأمان.',
'scheduled_overlay_title' => 'هذا المنشور مجدول',
'scheduled_overlay_subtitle' => 'مجدول لـ :date. ألغِ جدولته أولًا لإجراء تغييرات.',
'unschedule_cta' => 'ألغِ الجدولة للتعديل',
'tabs' => [
'preview' => 'معاينة',
'channels' => 'القنوات',
'comments' => 'التعليقات',
'comments_empty' => 'لا توجد تعليقات بعد.',
],
'media_picker' => [
'title' => 'اختر من المعرض',
'search' => 'البحث في الوسائط...',
'empty' => 'لا توجد وسائط في معرضك بعد',
'cancel' => 'إلغاء',
'add' => 'إضافة',
'add_count' => 'إضافة :count',
],
'alt_text' => [
'label' => 'نص بديل',
'edit' => 'تحرير النص البديل',
'placeholder' => 'صف هذه الصورة للأشخاص الذين يستخدمون برامج قراءة الشاشة',
'hint' => 'يُنشر على المنصات التي تدعم أوصاف الصور.',
'save' => 'حفظ',
],
'emoji_picker' => [
'search' => 'البحث عن رمز تعبيري',
'empty' => 'لم يتم العثور على رموز تعبيرية',
'recent' => 'المستخدمة كثيرًا',
'smileys' => 'الوجوه والمشاعر',
'people' => 'الأشخاص والجسم',
'nature' => 'الحيوانات والطبيعة',
'food' => 'الطعام والشراب',
'activities' => 'الأنشطة',
'travel' => 'السفر والأماكن',
'objects' => 'الأشياء',
'symbols' => 'الرموز',
'flags' => 'الأعلام',
],
'status' => [
'pending' => 'قيد الانتظار',
'scheduled' => 'مجدول',
'published' => 'منشور',
'publishing' => 'جارٍ النشر...',
'retrying' => 'جارٍ إعادة المحاولة...',
'failed' => 'فشل',
],
'delete_modal' => [
'title' => 'حذف المنشور',
'description' => 'هل أنت متأكد من رغبتك في حذف هذا المنشور؟ لا يمكن التراجع عن هذا الإجراء.',
'action' => 'حذف',
'cancel' => 'إلغاء',
],
'sync_enable' => [
'title' => 'تفعيل المزامنة؟',
'description' => 'ستشترك جميع المنصات في نفس المحتوى. سيتم استبدال أي تعديلات مخصصة أُجريت على منصات فردية بالمحتوى الحالي.',
'cancel' => 'إلغاء',
'action' => 'تفعيل المزامنة',
],
'sync_disable' => [
'title' => 'تعطيل المزامنة؟',
'description' => 'ستحتفظ كل منصة بمحتواها الحالي، لكن التعديلات المستقبلية ستُطبَّق فقط على المنصة التي تعدّلها.',
'customize_note' => 'ستتمكن من تخصيص المحتوى لكل منصة على حدة.',
'cancel' => 'إلغاء',
'action' => 'تعطيل المزامنة',
],
'platforms_dialog' => [
'title' => 'اختيار المنصات',
'description' => 'اختر المنصات التي تريد نشر هذا المنشور عليها.',
],
'signatures_modal' => [
'search' => 'البحث في التوقيعات...',
'no_results' => 'لم يتم العثور على توقيعات.',
],
'validation' => [
'select_board' => 'اختر لوحة',
'images_not_supported' => 'الصور غير مدعومة',
'videos_not_supported' => 'مقاطع الفيديو غير مدعومة',
'max_images' => 'الحد الأقصى :count صور',
'requires_media' => 'يتطلب وسائط',
'requires_content' => 'المحتوى النصي مطلوب',
'exceeded' => 'تجاوز :count',
'does_not_support_images' => 'لا يدعم :platform الصور',
'supports_up_to_images' => 'يدعم :platform حتى :count صور',
'does_not_support_videos' => 'لا يدعم :platform مقاطع الفيديو',
],
],
'content_types' => [
'instagram_feed' => [
'label' => 'منشور',
'description' => 'يظهر في خلاصتك وملفك الشخصي',
],
'instagram_reel' => [
'label' => 'ريل',
'description' => 'فيديو قصير حتى 90 ثانية',
],
'instagram_story' => [
'label' => 'قصة',
'description' => 'يختفي بعد 24 ساعة',
],
'linkedin_post' => [
'label' => 'منشور',
'description' => 'منشور قياسي — صورة واحدة أو متعددة أو فيديو أو PDF',
],
'linkedin_page_post' => [
'label' => 'منشور',
'description' => 'منشور قياسي — صورة واحدة أو متعددة أو فيديو أو PDF',
],
'facebook_post' => [
'label' => 'منشور',
'description' => 'منشور قياسي على صفحتك',
],
'facebook_reel' => [
'label' => 'ريل',
'description' => 'فيديو قصير حتى 90 ثانية',
],
'facebook_story' => [
'label' => 'قصة',
'description' => 'قصة فيديو عمودية، حتى 60 ثانية',
],
'tiktok_video' => [
'label' => 'فيديو',
'description' => 'محتوى فيديو قصير',
],
'tiktok_photo' => [
'label' => 'عرض صور دائري',
'description' => 'حتى 35 صورة كعرض دائري قابل للتمرير',
],
'youtube_short' => [
'label' => 'مقطع قصير',
'description' => 'فيديو عمودي حتى 60 ثانية',
],
'x_post' => [
'label' => 'منشور',
'description' => 'تغريدة بنص ووسائط',
],
'threads_post' => [
'label' => 'منشور',
'description' => 'منشور نصي مع وسائط اختيارية',
],
'pinterest_pin' => [
'label' => 'دبوس',
'description' => 'دبوس صورة قياسي',
],
'pinterest_video_pin' => [
'label' => 'دبوس فيديو',
'description' => 'دبوس فيديو (4 ث - 15 د)',
],
'pinterest_carousel' => [
'label' => 'عرض دائري',
'description' => 'عرض دائري متعدد الصور (2-5 صور)',
],
'bluesky_post' => [
'label' => 'منشور',
'description' => 'منشور نصي مع صور اختيارية',
],
'mastodon_post' => [
'label' => 'منشور',
'description' => 'منشور نصي مع وسائط اختيارية',
],
'telegram_post' => [
'label' => 'منشور',
'description' => 'منشور نصي مع وسائط اختيارية',
],
'discord_message' => [
'label' => 'رسالة',
'description' => 'رسالة إلى قناة Discord مع وسائط وتضمينات اختيارية',
],
],
'platforms' => [
'linkedin' => 'LinkedIn',
'linkedin-page' => 'LinkedIn Page',
'x' => 'X',
'tiktok' => 'TikTok',
'youtube' => 'YouTube Shorts',
'facebook' => 'Facebook Page',
'instagram' => 'Instagram',
'threads' => 'Threads',
'pinterest' => 'Pinterest',
'bluesky' => 'Bluesky',
'mastodon' => 'Mastodon',
],
'flash' => [
'scheduled' => 'تمت جدولة المنشور بنجاح!',
'deleted' => 'تم حذف المنشور بنجاح!',
'duplicated' => 'تم تكرار المنشور كمسودة.',
'cannot_edit_finalized' => 'تمت معالجة هذا المنشور بالفعل ولا يمكن إعادة نشره. كرّره للمحاولة مرة أخرى.',
'cannot_delete_published' => 'لا يمكن حذف المنشورات المنشورة.',
'connect_first' => 'اربط شبكة اجتماعية واحدة على الأقل قبل إنشاء منشور.',
],
'errors' => [
'account_disconnected' => 'الحساب الاجتماعي مفصول',
'account_inactive' => 'الحساب الاجتماعي مُعطَّل',
'account_token_expired' => 'انتهت جلسة الحساب الاجتماعي — يرجى إعادة الربط',
],
'delete' => [
'title' => 'حذف المنشور؟',
'description' => 'لا يمكن التراجع عن هذا الإجراء. ستتم إزالة المنشور وجميع وسائطه نهائيًا.',
'confirm' => 'نعم، احذف',
'cancel' => 'إلغاء',
],
'create' => [
'title' => 'إنشاء منشور جديد',
'description' => 'اختر كيف تريد البدء.',
'scratch_title' => 'البدء من الصفر',
'scratch_description' => 'افتح منشورًا فارغًا واكتب كل شيء بنفسك.',
'ai_title' => 'الإنشاء بالذكاء الاصطناعي',
'ai_description' => 'صِف ما تريده وينشئ الذكاء الاصطناعي المحتوى من أجلك.',
'ai_configure_description' => 'اختر تنسيقًا وصِف المنشور الذي تريد إنشاءه.',
'ai_pick_template_description' => 'اختر نمطًا لمنشورك المُنشأ بالذكاء الاصطناعي.',
'template_title' => 'استخدام قالب',
'template_description' => 'اختر من قوالبنا المنسّقة وخصّصها.',
'coming_soon' => 'قريبًا',
'preview' => [
'image_title' => 'عنوان الصورة',
'image_body' => 'نص الصورة',
],
'steps' => [
'template_picker_title' => 'اختر نمطًا',
'format_title' => 'اختر تنسيقًا',
'format_description' => 'اختر نوع المنشور الذي تريد إنشاءه.',
'account_title' => 'اختر حسابًا',
'account_description' => 'اختر الحساب الاجتماعي للنشر إليه.',
'media_title' => 'خيارات الوسائط',
'media_carousel' => 'كم عدد الشرائح؟',
'media_optional' => 'تضمين صور؟',
'media_optional_label' => 'كم عدد الصور؟',
'media_none' => 'بلا',
'media_count_label' => 'عدد الصور',
'prompt_title' => 'صِف منشورك',
'prompt_label' => 'ما موضوع هذا المنشور؟',
'prompt_placeholder' => 'مثال: الإعلان عن ميزة العرض الدائري الجديدة لدينا على Instagram',
'preview_error' => 'حدث خطأ ما. يرجى المحاولة مرة أخرى.',
'loading_page_title' => 'جارٍ إنشاء منشورك',
'loading_eta' => 'الوقت المقدّر: حوالي :minutes.',
'loading_eta_minute_one' => 'دقيقة واحدة',
'loading_eta_minute_other' => ':count دقيقة',
'loading_leave_title' => 'يمكنك متابعة عملك.',
'loading_leave_body' => 'سنُعلمك عندما يصبح المنشور جاهزًا.',
'loading_leave_cta' => 'الذهاب إلى التقويم',
'loading_create_another_cta' => 'إنشاء منشور آخر',
'loading_tip_credits' => 'تستخدم كل صورة بالذكاء الاصطناعي حوالي 15 من الرصيد.',
'loading_tip_edit' => 'ستتمكن من تعديل كل شيء بمجرد أن يصبح المنشور جاهزًا.',
'loading_tip_draft' => 'تصل المنشورات المُنشأة إلى مسوداتك.',
'loading_tip_brand' => 'عدّل إعدادات علامتك التجارية للتأثير في المنشورات المستقبلية.',
'loading_tip_carousel' => 'تقدّم العروض الدائرية شريحة واحدة لكل صورة مرفوعة.',
'loading_tip_quality' => 'تم ضبط جودة الصورة لتحقيق توازن بين السرعة والتكلفة.',
'create' => 'إنشاء منشور',
'back' => 'رجوع',
'next' => 'متابعة',
'cancel' => 'إلغاء',
'discard' => 'تجاهل',
'retry' => 'إعادة المحاولة',
'no_platforms' => 'لا توجد حسابات متصلة',
'connect_first' => 'اربط حسابًا اجتماعيًا واحدًا على الأقل لاستخدام الإنشاء بالذكاء الاصطناعي.',
'no_account_for_template' => 'اربط حسابًا متوافقًا لاستخدام هذا القالب.',
'format' => [
'instagram_feed' => 'منشور خلاصة Instagram',
'instagram_carousel' => 'عرض Instagram الدائري',
'linkedin_post' => 'منشور LinkedIn',
'linkedin_page_post' => 'منشور صفحة LinkedIn',
'x_post' => 'منشور X',
'bluesky_post' => 'منشور Bluesky',
'threads_post' => 'منشور Threads',
'mastodon_post' => 'منشور Mastodon',
'telegram_post' => 'منشور Telegram',
'discord_message' => 'رسالة Discord',
'facebook_post' => 'منشور Facebook',
'pinterest_pin' => 'دبوس Pinterest',
'instagram_story' => 'قصة Instagram',
'facebook_story' => 'قصة Facebook',
],
],
],
'templates' => [
'browser_title' => 'اختر قالبًا',
'browser_description' => 'ابدأ من قالب منسّق وكيّفه.',
'search_placeholder' => 'البحث في القوالب…',
'no_search_results' => 'لا توجد قوالب مطابقة لبحثك',
'try_different_search' => 'جرّب كلمة مختلفة أو امسح البحث.',
'slides_count' => '{count} شرائح|{count} شريحة|{count} شريحتان|{count} شرائح|{count} شريحة|{count} شريحة',
'all_platforms' => 'جميع المنصات',
'platform_search_placeholder' => 'البحث عن منصة…',
'no_platform_match' => 'لا توجد منصة مطابقة.',
'use_this' => 'استخدام هذا القالب',
'no_templates' => 'لا توجد قوالب متاحة.',
'applying' => 'جارٍ تطبيق القالب…',
'category' => [
'product_launch' => 'إطلاق منتج',
'promotion' => 'ترويج',
'educational' => 'تعليمي',
'behind_the_scenes' => 'خلف الكواليس',
'testimonial' => 'شهادة',
'industry_tip' => 'نصيحة في المجال',
'event' => 'فعالية',
'engagement' => 'تفاعل',
],
],
];

363
lang/ar/settings.php Normal file
View file

@ -0,0 +1,363 @@
<?php
return [
'title' => 'الإعدادات',
'description' => 'إدارة ملفك الشخصي وإعدادات حسابك',
'hub' => [
'title' => 'الإعدادات',
'description' => 'اختر ما تريد إدارته.',
'profile' => [
'title' => 'الملف الشخصي',
'description' => 'حدّث معلوماتك الشخصية وكلمة المرور وتفضيلات الإشعارات.',
],
'workspace' => [
'title' => 'مساحة العمل',
'description' => 'اضبط مساحة عملك وعلامتك التجارية والأعضاء ومفاتيح API.',
],
'account' => [
'title' => 'الحساب',
'description' => 'إدارة معلومات حسابك واستخدامك والفوترة.',
],
],
'nav' => [
'profile' => 'الملف الشخصي',
'authentication' => 'المصادقة',
'workspace' => 'مساحة العمل',
'members' => 'الأعضاء',
'notifications' => 'الإشعارات',
'billing' => 'الفوترة',
],
'notifications' => [
'title' => 'تفضيلات الإشعارات',
'heading' => 'إشعارات البريد الإلكتروني',
'description' => 'اختر إشعارات البريد الإلكتروني التي تريد استلامها',
'post_published' => 'تم نشر المنشور',
'post_published_description' => 'استلم بريدًا إلكترونيًا عند نشر منشورك بنجاح',
'post_failed' => 'فشل نشر المنشور',
'post_failed_description' => 'استلم بريدًا إلكترونيًا عند فشل نشر منشورك',
'account_disconnected' => 'تم فصل الحساب',
'account_disconnected_description' => 'استلم بريدًا إلكترونيًا عند فصل حساب اجتماعي',
'save' => 'حفظ التفضيلات',
],
'profile' => [
'title' => 'إعدادات الملف الشخصي',
'photo_heading' => 'الصورة الشخصية',
'photo_description' => 'ارفع صورة شخصية',
'heading' => 'معلومات الملف الشخصي',
'description' => 'حدّث اسمك وبريدك الإلكتروني',
'avatar' => 'الصورة الرمزية',
'name' => 'الاسم',
'name_placeholder' => 'الاسم الكامل',
'email' => 'البريد الإلكتروني',
'email_placeholder' => 'البريد الإلكتروني',
'email_unverified' => 'لم يتم تأكيد بريدك الإلكتروني.',
'resend_verification' => 'انقر هنا لإعادة إرسال بريد التأكيد.',
'verification_sent' => 'تم إرسال رابط تأكيد جديد إلى بريدك الإلكتروني.',
'save' => 'حفظ',
],
'authentication' => [
'title' => 'المصادقة',
'page_title' => 'إعدادات المصادقة',
'sessions' => [
'title' => 'الجلسات النشطة',
'description' => 'إذا لاحظت أي شيء مريب، سجّل الخروج من الأجهزة الأخرى.',
'unknown_browser' => 'متصفح غير معروف',
'unknown_ip' => 'عنوان IP غير معروف',
'on' => 'على',
'active_now' => 'نشط الآن',
'log_out_others' => 'تسجيل الخروج من الأجهزة الأخرى',
'modal_title' => 'تسجيل الخروج من الأجهزة الأخرى',
'modal_description_password' => 'أدخل كلمة المرور الحالية لتأكيد رغبتك في تسجيل الخروج من جلسات المتصفح الأخرى.',
'modal_description_email' => 'اكتب بريدك الإلكتروني لتأكيد رغبتك في تسجيل الخروج من جلسات المتصفح الأخرى.',
'password_placeholder' => 'كلمة المرور الحالية',
'email_placeholder' => 'بريد حسابك الإلكتروني',
'cancel' => 'إلغاء',
'submit' => 'تسجيل الخروج من الأجهزة الأخرى',
'email_mismatch' => 'البريد الإلكتروني لا يطابق حسابك.',
'flash_logged_out' => 'تم تسجيل خروجك من الأجهزة الأخرى.',
],
'password' => [
'update_title' => 'تحديث كلمة المرور',
'set_title' => 'تعيين كلمة مرور',
'update_description' => 'تأكد من أن حسابك يستخدم كلمة مرور طويلة وعشوائية للحفاظ على أمانه.',
'set_description' => 'أضِف كلمة مرور لتتمكن من تسجيل الدخول دون مزوّد متصل.',
'current_password' => 'كلمة المرور الحالية',
'new_password' => 'كلمة المرور الجديدة',
'confirm_password' => 'تأكيد كلمة المرور',
'save' => 'حفظ كلمة المرور',
'set' => 'تعيين كلمة المرور',
],
'providers' => [
'title' => 'الحسابات المتصلة',
'description' => 'سجّل الدخول بشكل أسرع عبر هؤلاء المزوّدين المتصلين.',
'connected' => 'متصل',
'not_connected' => 'غير متصل',
'connect' => 'ربط',
'disconnect' => 'فصل',
'flash_disconnected' => 'تم فصل :provider بنجاح.',
'flash_connected' => 'تم ربط :provider بنجاح.',
'flash_already_linked' => 'حساب :provider هذا مرتبط بالفعل بمستخدم آخر.',
'flash_cannot_disconnect' => 'لا يمكنك فصل طريقة تسجيل الدخول الوحيدة لديك. عيّن كلمة مرور أو اربط مزوّدًا آخر أولًا.',
],
],
'delete_account' => [
'heading' => 'حذف الحساب',
'description' => 'احذف حسابك وجميع موارده',
'warning' => 'تحذير',
'warning_message' => 'يرجى المتابعة بحذر، لا يمكن التراجع عن هذا الإجراء.',
'button' => 'حذف الحساب',
'modal_title' => 'هل أنت متأكد من رغبتك في حذف حسابك؟',
'modal_description_password' => 'بمجرد حذف حسابك، ستُحذف جميع موارده وبياناته نهائيًا أيضًا. يرجى إدخال كلمة المرور للتأكيد.',
'modal_description_email' => 'بمجرد حذف حسابك، ستُحذف جميع موارده وبياناته نهائيًا أيضًا. يرجى كتابة بريدك الإلكتروني :email للتأكيد.',
'password' => 'كلمة المرور',
'password_placeholder' => 'كلمة المرور',
'email_placeholder' => 'بريد حسابك الإلكتروني',
'email_mismatch' => 'البريد الإلكتروني لا يطابق حسابك.',
'cancel' => 'إلغاء',
'confirm' => 'حذف الحساب',
],
'workspace' => [
'tabs' => [
'workspace' => 'مساحة العمل',
'brand' => 'العلامة التجارية',
'users' => 'الأعضاء',
'api_keys' => 'مفاتيح API',
],
'title' => 'إعدادات مساحة العمل',
'logo_heading' => 'شعار مساحة العمل',
'logo_description' => 'ارفع شعارًا لمساحة عملك',
'heading' => 'اسم مساحة العمل',
'description' => 'حدّث اسم مساحة عملك',
'members_heading' => 'الأعضاء',
'members_description' => 'إدارة أعضاء مساحة العمل والدعوات',
'name' => 'الاسم',
'name_placeholder' => 'مساحة عملي',
'save' => 'حفظ',
],
'brand' => [
'title' => 'العلامة التجارية',
'description' => 'اضبط هوية علامتك التجارية للمحتوى المُنشأ بالذكاء الاصطناعي.',
'name' => 'اسم مساحة العمل',
'name_placeholder' => 'علامتي التجارية',
'website' => 'الموقع الإلكتروني',
'website_placeholder' => 'https://yourbrand.com',
'brand_description' => 'الوصف',
'brand_description_placeholder' => 'أخبرنا عن علامتك التجارية، وما تفعله، ومن هو جمهورك...',
'voice' => 'صوت العلامة التجارية',
'voice_description' => 'اختر السمات التي تحدد أسلوب محتواك.',
'voice_group' => [
'pov' => 'وجهة النظر',
'formality' => 'الرسمية',
'energy' => 'الحيوية',
'humor' => 'الفكاهة',
'attitude' => 'الموقف',
'warmth' => 'الدفء',
'confidence' => 'الثقة',
'style' => 'الأسلوب',
],
'voice_trait' => [
'first_person' => 'ضمير المتكلم',
'second_person' => 'ضمير المخاطب (أنت)',
'third_person' => 'ضمير الغائب',
'formal' => 'رسمي',
'balanced' => 'متوازن',
'casual' => 'غير رسمي',
'calm' => 'هادئ',
'moderate' => 'معتدل',
'enthusiastic' => 'متحمس',
'vibrant' => 'نابض بالحيوية',
'serious' => 'جاد',
'dry' => 'ساخر / خفيّ',
'witty' => 'ذكي',
'playful' => 'مرح',
'respectful' => 'محترم',
'even_handed' => 'محايد ومنصف',
'bold' => 'جريء',
'provocative' => 'مثير للجدل',
'neutral' => 'محايد',
'friendly' => 'ودود',
'empathetic' => 'متعاطف',
'humble' => 'متواضع',
'confident' => 'واثق',
'assertive' => 'حازم',
'direct' => 'مباشر',
'concise' => 'موجز',
'transparent' => 'شفاف',
'no_hype' => 'بلا مبالغة',
'practical' => 'عملي',
'data_driven' => 'مبني على البيانات',
'storytelling' => 'سرد القصص',
'inspirational' => 'ملهم',
'educational' => 'تعليمي',
'technical' => 'تقني',
'minimalist' => 'رموز تعبيرية قليلة',
],
'brand_color' => 'لون العلامة التجارية',
'background_color' => 'لون الخلفية',
'text_color' => 'لون النص',
'font' => 'الخط',
'image_style' => 'نمط الصور',
'image_style_description' => 'النمط المرئي المطبَّق عند إنشاء صور الشرائح والأغلفة لمنشورات الذكاء الاصطناعي.',
'image_style_cinematic' => 'سينمائي',
'image_style_illustration' => 'رسم توضيحي',
'image_style_isometric_3d' => 'متساوي القياس',
'image_style_cartoon' => 'كرتوني',
'image_style_typographic' => 'طباعي',
'image_style_infographic' => 'إنفوغرافيك',
'image_style_minimalist' => 'بسيط',
'image_style_mockup' => 'نموذج أوّلي',
'content_language' => 'لغة المحتوى',
'content_language_description' => 'اللغة المستخدمة في التسميات التوضيحية والوسوم المُنشأة بالذكاء الاصطناعي وأي نص داخل الصور أو مقاطع الفيديو المُنشأة.',
'font_placeholder' => 'اختر خطًا…',
'font_search' => 'ابحث عن خط…',
'font_empty' => 'لا توجد خطوط مطابقة.',
'language_placeholder' => 'اختر لغة…',
'language_search' => 'ابحث عن لغة…',
'language_empty' => 'لم يتم العثور على لغة.',
],
'members' => [
'title' => 'الأعضاء',
'heading' => 'أعضاء الفريق',
'description' => 'إدارة الأعضاء والدعوات لمساحة العمل هذه',
'cancel' => 'إلغاء',
'remove' => 'إزالة',
'make_role' => 'تعيين كـ:role',
'invite' => [
'title' => 'دعوة عضو',
'description' => 'أرسل دعوة عبر البريد الإلكتروني لإضافة متعاونين',
'email' => 'البريد الإلكتروني',
'email_placeholder' => 'collaborator@email.com',
'role' => 'الدور',
'role_placeholder' => 'اختر دورًا',
'submit' => 'إرسال الدعوة',
],
'pending' => [
'title' => 'الدعوات المعلقة',
'description' => 'دعوات في انتظار القبول',
'empty' => 'لا توجد دعوات معلقة',
],
'list' => [
'title' => 'الأعضاء',
'description' => 'الأشخاص الذين لديهم وصول إلى مساحة العمل هذه',
'empty' => 'لا يوجد أعضاء غير المالك',
],
'remove_modal' => [
'title' => 'إزالة عضو',
'description' => 'هل أنت متأكد من رغبتك في إزالة هذا العضو من مساحة العمل؟ سيفقد الوصول إلى جميع موارد مساحة العمل.',
'action' => 'إزالة العضو',
],
'cancel_invite_modal' => [
'title' => 'إلغاء الدعوة',
'description' => 'هل أنت متأكد من رغبتك في إلغاء هذه الدعوة؟',
'action' => 'إلغاء الدعوة',
],
'roles' => [
'owner' => 'المالك',
'admin' => 'مشرف',
'member' => 'عضو',
'viewer' => 'مشاهد',
],
'flash' => [
'invite_sent' => 'تم إرسال الدعوة بنجاح!',
'invite_deleted' => 'تم حذف الدعوة.',
'member_removed' => 'تمت إزالة العضو بنجاح.',
'role_updated' => 'تم تحديث دور العضو.',
'wrong_email' => 'هذه الدعوة مخصصة لبريد إلكتروني مختلف.',
'already_member' => 'أنت عضو بالفعل في مساحة العمل هذه.',
'invite_accepted' => 'مرحبًا! أنت الآن عضو في مساحة العمل.',
'invite_declined' => 'تم رفض الدعوة.',
],
],
'account' => [
'tabs' => [
'account' => 'الحساب',
'usage' => 'الاستخدام',
'billing' => 'الفوترة',
],
'title' => 'إعدادات الحساب',
'description' => 'إدارة اسم حسابك والبريد الإلكتروني للفوترة',
'name' => 'اسم الحساب',
'name_placeholder' => 'شركتي',
'billing_email' => 'البريد الإلكتروني للفوترة',
'billing_email_placeholder' => 'billing@company.com',
'billing_email_hint' => 'سيُستخدم هذا البريد الإلكتروني للفواتير ومراسلات الفوترة من Stripe.',
'submit' => 'حفظ',
],
'flash' => [
'account_updated' => 'تم تحديث الحساب بنجاح!',
'profile_updated' => 'تم تحديث الملف الشخصي بنجاح!',
'password_updated' => 'تم تحديث كلمة المرور بنجاح!',
'workspace_updated' => 'تم تحديث الإعدادات بنجاح!',
'photo_updated' => 'تم تحديث الصورة بنجاح!',
'photo_deleted' => 'تمت إزالة الصورة بنجاح!',
'logo_updated' => 'تم رفع الشعار بنجاح!',
'logo_deleted' => 'تمت إزالة الشعار بنجاح!',
'notifications_updated' => 'تم تحديث تفضيلات الإشعارات!',
],
'api_keys' => [
'title' => 'مفاتيح API',
'page_title' => 'مفاتيح API',
'heading' => 'مفاتيح API',
'description' => 'إدارة مفاتيح API للوصول البرمجي إلى مساحة عملك.',
'create' => 'إنشاء مفتاح API',
'copy' => 'نسخ',
'new_token_message' => 'تم إنشاء مفتاح API الجديد. انسخه الآن — لن تتمكن من رؤيته مرة أخرى.',
'table' => [
'name' => 'الاسم',
'key' => 'المفتاح',
'status' => 'الحالة',
'expires' => 'ينتهي في',
'last_used' => 'آخر استخدام',
'never' => 'أبدًا',
],
'actions' => [
'copy_id' => 'نسخ معرّف مفتاح API',
'copy_id_success' => 'تم نسخ معرّف مفتاح API إلى الحافظة',
'delete' => 'حذف',
],
'empty' => [
'title' => 'لا توجد مفاتيح API بعد',
'description' => 'أنشئ مفتاح API للوصول إلى مساحة عملك برمجيًا.',
],
'delete_modal' => [
'title' => 'حذف مفتاح API',
'description' => 'هل أنت متأكد من رغبتك في حذف مفتاح API هذا؟ ستفقد أي تطبيقات تستخدم هذا المفتاح الوصول فورًا.',
'action' => 'حذف مفتاح API',
],
'create_dialog' => [
'title' => 'إنشاء مفتاح API',
'description' => 'أنشئ مفتاح API جديدًا للوصول البرمجي إلى مساحة عملك.',
'name' => 'الاسم',
'name_placeholder' => 'مثال: مفتاح API للإنتاج',
'expires' => 'تاريخ الانتهاء (اختياري)',
'expires_placeholder' => 'بلا انتهاء',
'submit' => 'إنشاء',
'cancel' => 'إلغاء',
],
'flash' => [
'created' => 'تم إنشاء مفتاح API بنجاح!',
'deleted' => 'تم حذف مفتاح API بنجاح!',
],
],
];

61
lang/ar/sidebar.php Normal file
View file

@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
return [
'workspaces' => 'مساحات العمل',
'select_workspace' => 'اختر مساحة العمل',
'create_workspace' => 'إنشاء مساحة عمل',
'create_post' => 'إنشاء منشور',
'profile' => 'الملف الشخصي',
'log_out' => 'تسجيل الخروج',
'workspace' => 'مساحة العمل: :name',
'workspace_select' => 'مساحة العمل: اختر',
'theme' => 'المظهر: :name',
'theme_light' => 'فاتح',
'theme_dark' => 'داكن',
'theme_system' => 'النظام',
'language' => 'اللغة: :name',
'language_select' => 'اللغة: اختر',
'groups' => [
'posts' => 'المنشورات',
'workspace' => 'مساحة العمل',
'others' => 'أخرى',
],
'analytics' => 'التحليلات',
'automations' => 'الأتمتة',
'settings' => 'الإعدادات',
'posts' => [
'calendar' => 'التقويم',
'all' => 'الكل',
'scheduled' => 'المجدولة',
'posted' => 'المنشورة',
'drafts' => 'المسودات',
],
'workspace' => [
'connections' => 'الاتصالات',
'signatures' => 'التوقيعات',
'labels' => 'التسميات',
'assets' => 'الوسائط',
'api_keys' => 'مفاتيح API',
],
'notifications' => 'الإشعارات',
'mark_all_read' => 'تعليم الكل كمقروء',
'mark_as_read' => 'تعليم كمقروء',
'archive_all' => 'أرشفة الكل',
'no_notifications' => 'لا توجد إشعارات',
'support' => [
'docs' => 'التوثيق',
'referral' => 'اربح عمولة إحالة 30%',
'stay_updated' => 'ابقَ على اطلاع',
],
];

59
lang/ar/signatures.php Normal file
View file

@ -0,0 +1,59 @@
<?php
return [
'title' => 'التوقيعات',
'description' => 'أنشئ توقيعات قابلة لإعادة الاستخدام لإضافتها بسرعة إلى منشوراتك',
'search' => 'البحث في التوقيعات...',
'new' => 'توقيع جديد',
'empty_title' => 'لا توجد توقيعات بعد',
'empty_description' => 'أنشئ توقيعات لإضافة الوسوم أو الروابط أو أي نص قابل لإعادة الاستخدام إلى منشوراتك بسرعة',
'no_search_results' => 'لا توجد توقيعات مطابقة لبحثك',
'try_different_search' => 'جرّب كلمة مختلفة أو امسح البحث.',
'table' => [
'name' => 'الاسم',
'content' => 'المحتوى',
'created_at' => 'تاريخ الإنشاء',
],
'actions' => [
'edit' => 'تعديل التوقيع',
'delete' => 'حذف التوقيع',
],
'create' => [
'title' => 'إنشاء توقيع',
'description' => 'امنح توقيعك اسمًا والمحتوى الذي تريد إضافته (وسوم، روابط، نص مخصص — أي شيء تعيد استخدامه).',
'name' => 'الاسم',
'name_placeholder' => 'مثال: التسويق، السفر، توقيع العلامة التجارية',
'content' => 'المحتوى',
'content_placeholder' => "#marketing #socialmedia\nاعرف المزيد: https://yourbrand.com",
'content_hint' => 'وسوم، روابط، مقدمات مخصصة، توقيعات — أي شيء تضيفه إلى المنشورات.',
'submit' => 'إنشاء توقيع',
'submitting' => 'جارٍ الإنشاء...',
],
'edit' => [
'title' => 'تعديل التوقيع',
'description' => 'تحديث اسم ومحتوى هذا التوقيع.',
'name' => 'الاسم',
'name_placeholder' => 'مثال: التسويق، السفر، توقيع العلامة التجارية',
'content' => 'المحتوى',
'content_placeholder' => "#marketing #socialmedia\nاعرف المزيد: https://yourbrand.com",
'content_hint' => 'وسوم، روابط، مقدمات مخصصة، توقيعات — أي شيء تضيفه إلى المنشورات.',
'submit' => 'حفظ التغييرات',
'submitting' => 'جارٍ الحفظ...',
],
'delete' => [
'title' => 'حذف التوقيع',
'description' => 'هل أنت متأكد من رغبتك في حذف هذا التوقيع؟ لا يمكن التراجع عن هذا الإجراء.',
'confirm' => 'حذف',
'cancel' => 'إلغاء',
],
'flash' => [
'created' => 'تم إنشاء التوقيع.',
'updated' => 'تم تحديث التوقيع.',
'deleted' => 'تم حذف التوقيع.',
],
];

15
lang/ar/usage.php Normal file
View file

@ -0,0 +1,15 @@
<?php
return [
'title' => 'الاستخدام',
'section_account' => 'الحساب',
'section_account_description' => 'الحصص والحدود الخاصة بخطتك.',
'section_ai' => 'رصيد الذكاء الاصطناعي',
'section_ai_description' => 'يُخصم الرصيد عند استخدام ميزات الذكاء الاصطناعي. يتم تجديده في اليوم الأول من كل شهر.',
'workspaces' => 'مساحات العمل',
'social_accounts' => 'الحسابات الاجتماعية',
'members' => 'الأعضاء',
'credits' => 'الرصيد',
];

200
lang/ar/validation.php Normal file
View file

@ -0,0 +1,200 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Validation Language Lines
|--------------------------------------------------------------------------
|
| The following language lines contain the default error messages used by
| the validator class. Some of these rules have multiple versions such
| as the size rules. Feel free to tweak each of these messages here.
|
*/
'accepted' => 'يجب قبول حقل :attribute.',
'accepted_if' => 'يجب قبول حقل :attribute عندما يكون :other مساويًا لـ :value.',
'active_url' => 'يجب أن يكون حقل :attribute رابطًا صالحًا.',
'after' => 'يجب أن يكون حقل :attribute تاريخًا لاحقًا لـ :date.',
'after_or_equal' => 'يجب أن يكون حقل :attribute تاريخًا لاحقًا لـ :date أو مساويًا له.',
'alpha' => 'يجب أن يحتوي حقل :attribute على أحرف فقط.',
'alpha_dash' => 'يجب أن يحتوي حقل :attribute على أحرف وأرقام وشرطات وشرطات سفلية فقط.',
'alpha_num' => 'يجب أن يحتوي حقل :attribute على أحرف وأرقام فقط.',
'any_of' => 'حقل :attribute غير صالح.',
'array' => 'يجب أن يكون حقل :attribute مصفوفة.',
'ascii' => 'يجب أن يحتوي حقل :attribute على أحرف ورموز أحادية البايت فقط.',
'before' => 'يجب أن يكون حقل :attribute تاريخًا سابقًا لـ :date.',
'before_or_equal' => 'يجب أن يكون حقل :attribute تاريخًا سابقًا لـ :date أو مساويًا له.',
'between' => [
'array' => 'يجب أن يحتوي حقل :attribute على عدد عناصر بين :min و:max.',
'file' => 'يجب أن يكون حجم حقل :attribute بين :min و:max كيلوبايت.',
'numeric' => 'يجب أن تكون قيمة حقل :attribute بين :min و:max.',
'string' => 'يجب أن يكون طول حقل :attribute بين :min و:max حرفًا.',
],
'boolean' => 'يجب أن تكون قيمة حقل :attribute صحيحة أو خاطئة.',
'can' => 'يحتوي حقل :attribute على قيمة غير مصرّح بها.',
'confirmed' => 'تأكيد حقل :attribute غير مطابق.',
'contains' => 'حقل :attribute يفتقد إلى قيمة مطلوبة.',
'current_password' => 'كلمة المرور غير صحيحة.',
'date' => 'يجب أن يكون حقل :attribute تاريخًا صالحًا.',
'date_equals' => 'يجب أن يكون حقل :attribute تاريخًا مساويًا لـ :date.',
'date_format' => 'يجب أن يطابق حقل :attribute التنسيق :format.',
'decimal' => 'يجب أن يحتوي حقل :attribute على :decimal منزلة عشرية.',
'declined' => 'يجب رفض حقل :attribute.',
'declined_if' => 'يجب رفض حقل :attribute عندما يكون :other مساويًا لـ :value.',
'different' => 'يجب أن يختلف حقل :attribute عن :other.',
'digits' => 'يجب أن يتكوّن حقل :attribute من :digits رقمًا.',
'digits_between' => 'يجب أن يتكوّن حقل :attribute من عدد أرقام بين :min و:max.',
'dimensions' => 'أبعاد صورة حقل :attribute غير صالحة.',
'distinct' => 'يحتوي حقل :attribute على قيمة مكرّرة.',
'doesnt_contain' => 'يجب ألا يحتوي حقل :attribute على أي مما يلي: :values.',
'doesnt_end_with' => 'يجب ألا ينتهي حقل :attribute بأي مما يلي: :values.',
'doesnt_start_with' => 'يجب ألا يبدأ حقل :attribute بأي مما يلي: :values.',
'email' => 'يجب أن يكون حقل :attribute بريدًا إلكترونيًا صالحًا.',
'encoding' => 'يجب أن يكون ترميز حقل :attribute بصيغة :encoding.',
'ends_with' => 'يجب أن ينتهي حقل :attribute بأحد ما يلي: :values.',
'enum' => 'قيمة :attribute المحدّدة غير صالحة.',
'exists' => 'قيمة :attribute المحدّدة غير صالحة.',
'extensions' => 'يجب أن يحمل حقل :attribute أحد الامتدادات التالية: :values.',
'file' => 'يجب أن يكون حقل :attribute ملفًا.',
'filled' => 'يجب أن يحتوي حقل :attribute على قيمة.',
'gt' => [
'array' => 'يجب أن يحتوي حقل :attribute على أكثر من :value عنصرًا.',
'file' => 'يجب أن يكون حجم حقل :attribute أكبر من :value كيلوبايت.',
'numeric' => 'يجب أن تكون قيمة حقل :attribute أكبر من :value.',
'string' => 'يجب أن يكون طول حقل :attribute أكبر من :value حرفًا.',
],
'gte' => [
'array' => 'يجب أن يحتوي حقل :attribute على :value عنصرًا أو أكثر.',
'file' => 'يجب أن يكون حجم حقل :attribute أكبر من :value كيلوبايت أو مساويًا له.',
'numeric' => 'يجب أن تكون قيمة حقل :attribute أكبر من :value أو مساوية له.',
'string' => 'يجب أن يكون طول حقل :attribute أكبر من :value حرفًا أو مساويًا له.',
],
'hex_color' => 'يجب أن يكون حقل :attribute لونًا سداسيًا صالحًا.',
'image' => 'يجب أن يكون حقل :attribute صورة.',
'in' => 'قيمة :attribute المحدّدة غير صالحة.',
'in_array' => 'يجب أن يكون حقل :attribute موجودًا في :other.',
'in_array_keys' => 'يجب أن يحتوي حقل :attribute على واحد على الأقل من المفاتيح التالية: :values.',
'integer' => 'يجب أن يكون حقل :attribute عددًا صحيحًا.',
'ip' => 'يجب أن يكون حقل :attribute عنوان IP صالحًا.',
'ipv4' => 'يجب أن يكون حقل :attribute عنوان IPv4 صالحًا.',
'ipv6' => 'يجب أن يكون حقل :attribute عنوان IPv6 صالحًا.',
'json' => 'يجب أن يكون حقل :attribute نص JSON صالحًا.',
'list' => 'يجب أن يكون حقل :attribute قائمة.',
'lowercase' => 'يجب أن يكون حقل :attribute بأحرف صغيرة.',
'lt' => [
'array' => 'يجب أن يحتوي حقل :attribute على أقل من :value عنصرًا.',
'file' => 'يجب أن يكون حجم حقل :attribute أقل من :value كيلوبايت.',
'numeric' => 'يجب أن تكون قيمة حقل :attribute أقل من :value.',
'string' => 'يجب أن يكون طول حقل :attribute أقل من :value حرفًا.',
],
'lte' => [
'array' => 'يجب ألا يحتوي حقل :attribute على أكثر من :value عنصرًا.',
'file' => 'يجب أن يكون حجم حقل :attribute أقل من :value كيلوبايت أو مساويًا له.',
'numeric' => 'يجب أن تكون قيمة حقل :attribute أقل من :value أو مساوية له.',
'string' => 'يجب أن يكون طول حقل :attribute أقل من :value حرفًا أو مساويًا له.',
],
'mac_address' => 'يجب أن يكون حقل :attribute عنوان MAC صالحًا.',
'max' => [
'array' => 'يجب ألا يحتوي حقل :attribute على أكثر من :max عنصرًا.',
'file' => 'يجب ألا يزيد حجم حقل :attribute عن :max كيلوبايت.',
'numeric' => 'يجب ألا تزيد قيمة حقل :attribute عن :max.',
'string' => 'يجب ألا يزيد طول حقل :attribute عن :max حرفًا.',
],
'max_digits' => 'يجب ألا يحتوي حقل :attribute على أكثر من :max رقمًا.',
'mimes' => 'يجب أن يكون حقل :attribute ملفًا من نوع: :values.',
'mimetypes' => 'يجب أن يكون حقل :attribute ملفًا من نوع: :values.',
'min' => [
'array' => 'يجب أن يحتوي حقل :attribute على :min عنصرًا على الأقل.',
'file' => 'يجب أن يكون حجم حقل :attribute :min كيلوبايت على الأقل.',
'numeric' => 'يجب أن تكون قيمة حقل :attribute :min على الأقل.',
'string' => 'يجب أن يكون طول حقل :attribute :min حرفًا على الأقل.',
],
'min_digits' => 'يجب أن يحتوي حقل :attribute على :min رقمًا على الأقل.',
'missing' => 'يجب أن يكون حقل :attribute غير موجود.',
'missing_if' => 'يجب أن يكون حقل :attribute غير موجود عندما يكون :other مساويًا لـ :value.',
'missing_unless' => 'يجب أن يكون حقل :attribute غير موجود ما لم يكن :other مساويًا لـ :value.',
'missing_with' => 'يجب أن يكون حقل :attribute غير موجود عند وجود :values.',
'missing_with_all' => 'يجب أن يكون حقل :attribute غير موجود عند وجود :values.',
'multiple_of' => 'يجب أن تكون قيمة حقل :attribute من مضاعفات :value.',
'not_in' => 'قيمة :attribute المحدّدة غير صالحة.',
'not_regex' => 'صيغة حقل :attribute غير صالحة.',
'numeric' => 'يجب أن يكون حقل :attribute رقمًا.',
'password' => [
'letters' => 'يجب أن يحتوي حقل :attribute على حرف واحد على الأقل.',
'mixed' => 'يجب أن يحتوي حقل :attribute على حرف كبير وحرف صغير على الأقل.',
'numbers' => 'يجب أن يحتوي حقل :attribute على رقم واحد على الأقل.',
'symbols' => 'يجب أن يحتوي حقل :attribute على رمز واحد على الأقل.',
'uncompromised' => 'ظهر حقل :attribute المُدخل في تسريب بيانات. يرجى اختيار :attribute مختلف.',
],
'present' => 'يجب أن يكون حقل :attribute موجودًا.',
'present_if' => 'يجب أن يكون حقل :attribute موجودًا عندما يكون :other مساويًا لـ :value.',
'present_unless' => 'يجب أن يكون حقل :attribute موجودًا ما لم يكن :other مساويًا لـ :value.',
'present_with' => 'يجب أن يكون حقل :attribute موجودًا عند وجود :values.',
'present_with_all' => 'يجب أن يكون حقل :attribute موجودًا عند وجود :values.',
'prohibited' => 'حقل :attribute محظور.',
'prohibited_if' => 'حقل :attribute محظور عندما يكون :other مساويًا لـ :value.',
'prohibited_if_accepted' => 'حقل :attribute محظور عند قبول :other.',
'prohibited_if_declined' => 'حقل :attribute محظور عند رفض :other.',
'prohibited_unless' => 'حقل :attribute محظور ما لم يكن :other ضمن :values.',
'prohibits' => 'يمنع حقل :attribute وجود :other.',
'regex' => 'صيغة حقل :attribute غير صالحة.',
'required' => 'حقل :attribute مطلوب.',
'required_array_keys' => 'يجب أن يحتوي حقل :attribute على إدخالات لـ: :values.',
'required_if' => 'حقل :attribute مطلوب عندما يكون :other مساويًا لـ :value.',
'required_if_accepted' => 'حقل :attribute مطلوب عند قبول :other.',
'required_if_declined' => 'حقل :attribute مطلوب عند رفض :other.',
'required_unless' => 'حقل :attribute مطلوب ما لم يكن :other ضمن :values.',
'required_with' => 'حقل :attribute مطلوب عند وجود :values.',
'required_with_all' => 'حقل :attribute مطلوب عند وجود :values.',
'required_without' => 'حقل :attribute مطلوب عند عدم وجود :values.',
'required_without_all' => 'حقل :attribute مطلوب عند عدم وجود أي من :values.',
'same' => 'يجب أن يطابق حقل :attribute قيمة :other.',
'size' => [
'array' => 'يجب أن يحتوي حقل :attribute على :size عنصرًا.',
'file' => 'يجب أن يكون حجم حقل :attribute :size كيلوبايت.',
'numeric' => 'يجب أن تكون قيمة حقل :attribute :size.',
'string' => 'يجب أن يكون طول حقل :attribute :size حرفًا.',
],
'starts_with' => 'يجب أن يبدأ حقل :attribute بأحد ما يلي: :values.',
'string' => 'يجب أن يكون حقل :attribute نصًا.',
'timezone' => 'يجب أن يكون حقل :attribute منطقة زمنية صالحة.',
'unique' => 'قيمة :attribute مُستخدمة بالفعل.',
'uploaded' => 'فشل رفع :attribute.',
'uppercase' => 'يجب أن يكون حقل :attribute بأحرف كبيرة.',
'url' => 'يجب أن يكون حقل :attribute رابطًا صالحًا.',
'ulid' => 'يجب أن يكون حقل :attribute معرّف ULID صالحًا.',
'uuid' => 'يجب أن يكون حقل :attribute معرّف UUID صالحًا.',
/*
|--------------------------------------------------------------------------
| Custom Validation Language Lines
|--------------------------------------------------------------------------
|
| Here you may specify custom validation messages for attributes using the
| convention "attribute.rule" to name the lines. This makes it quick to
| specify a specific custom language line for a given attribute rule.
|
*/
'custom' => [
'attribute-name' => [
'rule-name' => 'custom-message',
],
],
/*
|--------------------------------------------------------------------------
| Custom Validation Attributes
|--------------------------------------------------------------------------
|
| The following language lines are used to swap our attribute placeholder
| with something more reader friendly such as "E-Mail Address" instead
| of "email". This simply helps us make our message more expressive.
|
*/
'attributes' => [],
];

50
lang/ar/workspaces.php Normal file
View file

@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
return [
'title' => 'مساحات العمل',
'select_title' => 'مساحات العمل الخاصة بك',
'select_description' => 'اختر مساحة عمل للمتابعة',
'current' => 'الحالية',
'connections' => ':count اتصال',
'posts' => ':count منشور',
'create' => [
'page_title' => 'أنشئ مساحة عملك',
'title' => 'إعداد مساحة عملك',
'description' => 'أخبرنا قليلًا عنك أو عن مشروعك. سنستخدم ذلك لتخصيص المنشورات المُنشأة بالذكاء الاصطناعي لتتناسب مع أسلوبك.',
'website' => 'الموقع الإلكتروني',
'website_placeholder' => 'https://yourbrand.com',
'autofill' => 'تعبئة تلقائية من الموقع',
'autofill_missing_url' => 'أدخل رابطًا أولًا.',
'autofill_success' => 'تم تحميل معلومات العلامة التجارية.',
'autofill_error' => 'تعذرت التعبئة التلقائية. يمكنك ملء الحقول يدويًا.',
'autofill_errors' => [
'unreachable' => 'تعذر الوصول إلى ذلك الموقع (:reason).',
'http_status' => 'أعاد الموقع حالة غير متوقعة (:status).',
'invalid_scheme' => 'يتم دعم روابط http وhttps فقط.',
'missing_host' => 'الرابط يفتقد إلى المضيف.',
'unresolvable_host' => 'تعذر التعرف على المضيف (:host).',
'private_network' => 'الروابط التي تشير إلى الشبكات الخاصة غير مسموح بها.',
],
'logo_captured' => 'تم التقاط الشعار من موقعك الإلكتروني.',
'name' => 'اسم مساحة العمل',
'name_placeholder' => 'مثال: Acme Inc',
'brand_description' => 'وصف العلامة التجارية',
'brand_description_placeholder' => 'ماذا تفعل علامتك التجارية؟',
'content_language' => 'لغة المحتوى',
'content_language_description' => 'ستُكتب التسميات التوضيحية المُنشأة بالذكاء الاصطناعي بهذه اللغة.',
'brand_color' => 'لون العلامة التجارية',
'background_color' => 'لون الخلفية',
'text_color' => 'لون النص',
'submit' => 'إنشاء مساحة عمل',
'success' => 'تم إنشاء مساحة العمل. اربط حسابًا اجتماعيًا لبدء النشر.',
],
'cannot_delete_last' => 'لا يمكنك حذف مساحة العمل الوحيدة لديك. ألغِ اشتراكك من إعدادات الفوترة لإغلاق حسابك.',
'flash' => [
'deleted' => 'تم حذف مساحة العمل بنجاح.',
],
];

150
lang/de/accounts.php Normal file
View file

@ -0,0 +1,150 @@
<?php
declare(strict_types=1);
return [
'title' => 'Verbindungen',
'page_title' => 'Social-Media-Konten',
'description' => 'Übersicht über alle deine verbundenen Social-Media-Konten',
'connect_cta' => 'Verbinden',
'not_connected' => 'Nicht verbunden',
'connect' => 'Verbinden',
'connection_lost' => 'Verbindung verloren',
'reconnect' => 'Erneut verbinden',
'reconnect_account' => 'Konto erneut verbinden',
'view_profile' => 'Profil ansehen',
'disconnect' => 'Trennen',
'descriptions' => [
'linkedin' => 'Verbinde dein LinkedIn-Profil oder deine Unternehmensseite',
'linkedin-page' => 'Verbinde eine LinkedIn-Unternehmensseite',
'x' => 'Verbinde dein X-Konto (Twitter)',
'tiktok' => 'Verbinde dein TikTok-Konto',
'youtube' => 'Verbinde einen YouTube-Kanal',
'facebook' => 'Verbinde eine Facebook-Seite',
'instagram' => 'Verbinde ein professionelles Instagram-Konto',
'instagram-facebook' => 'Verbinde Instagram über eine Facebook-Seite',
'threads' => 'Verbinde dein Threads-Konto',
'pinterest' => 'Verbinde dein Pinterest-Konto',
'bluesky' => 'Verbinde dein Bluesky-Konto',
'mastodon' => 'Verbinde dein Mastodon-Konto',
'telegram' => 'Verbinde einen Telegram-Kanal oder eine Telegram-Gruppe',
'discord' => 'Verbinde einen Discord-Server',
],
'disconnect_modal' => [
'title' => 'Konto trennen',
'description' => 'Möchtest du dieses Konto wirklich trennen? Du kannst es jederzeit wieder verbinden.',
'confirm' => 'Trennen',
'cancel' => 'Abbrechen',
],
'bluesky' => [
'title' => 'Bluesky verbinden',
'description' => 'Gib deine Zugangsdaten ein, um dich zu verbinden',
'email' => 'E-Mail',
'email_placeholder' => 'yourhandle.bsky.social',
'app_password' => 'App-Passwort',
'app_password_placeholder' => 'xxxx-xxxx-xxxx-xxxx',
'app_password_hint' => 'Verwende aus Sicherheitsgründen ein <strong>App-Passwort</strong>. Erstelle eines unter <a href="https://bsky.app/settings/app-passwords" target="_blank" class="underline">bsky.app/settings</a>.',
'submit' => 'Bluesky verbinden',
'submitting' => 'Verbindung wird hergestellt...',
],
'mastodon' => [
'title' => 'Mastodon verbinden',
'description' => 'Gib deine Mastodon-Instanz ein',
'instance_url' => 'Instanz-URL',
'instance_placeholder' => 'https://mastodon.social',
'instance_hint' => 'Gib die URL deiner Mastodon-Instanz ein (z. B. mastodon.social, techhub.social)',
'submit' => 'Mit Mastodon fortfahren',
'submitting' => 'Verbindung wird hergestellt...',
],
'telegram' => [
'title' => 'Telegram verbinden',
'description' => 'Einen Kanal oder eine Gruppe verknüpfen',
'step_admin' => 'Füge :bot als Administrator zu deinem Telegram-Kanal oder deiner Telegram-Gruppe hinzu.',
'step_command' => 'Poste diesen Befehl im Kanal oder in der Gruppe:',
'waiting' => 'Warten auf die Verbindung des Kanals…',
'connected' => 'Kanal verbunden!',
'connected_toast' => 'Telegram-Kanal erfolgreich verbunden!',
'copied_toast' => 'Befehl in die Zwischenablage kopiert',
'copy_tooltip' => 'Befehl kopieren',
'expired' => 'Dieser Code ist abgelaufen. Erstelle einen neuen, um es erneut zu versuchen.',
'new_code' => 'Neuen Code erstellen',
'retry' => 'Erneut versuchen',
'error_generic' => 'Die Verbindung konnte nicht gestartet werden. Bitte versuche es erneut.',
'network_taken' => 'Dieser Workspace hat bereits einen verbundenen Telegram-Kanal. Trenne ihn zuerst.',
],
'facebook' => [
'title' => 'Facebook-Seite auswählen',
'description' => 'Wähle die Seite aus, die du verbinden möchtest',
'no_pages' => 'Keine Seiten gefunden',
'no_pages_description' => 'Du bist kein Administrator einer Facebook-Seite.',
'page_label' => 'Facebook-Seite',
'view' => 'Ansehen',
'choose' => 'Auswählen',
],
'instagram_facebook' => [
'title' => 'Instagram-Konto auswählen',
'description' => 'Wähle das Instagram-Konto aus, das du verbinden möchtest',
'no_pages' => 'Keine Instagram-Konten gefunden',
'no_pages_description' => 'Es wurden keine Facebook-Seiten mit verknüpften Instagram-Business-Konten gefunden.',
'view' => 'Ansehen',
'choose' => 'Auswählen',
],
'linkedin' => [
'title' => 'LinkedIn-Seite auswählen',
'description' => 'Wähle die Seite aus, die du verbinden möchtest',
'no_pages' => 'Keine Seiten gefunden',
'no_pages_description' => 'Du bist kein Administrator einer LinkedIn-Seite.',
'page_label' => 'LinkedIn-Seite',
'select_title' => 'Wo möchtest du posten?',
'select_subtitle' => 'Poste als du selbst oder wähle eine Unternehmensseite, die du verwaltest.',
'person_tag' => 'Person',
'organization_tag' => 'Organisation',
'view' => 'Ansehen',
'choose' => 'Auswählen',
],
'flash' => [
'disconnected' => 'Konto erfolgreich getrennt!',
'connected' => 'Konto erfolgreich verbunden!',
'session_expired' => 'Sitzung abgelaufen. Bitte versuche es erneut.',
'workspace_not_found' => 'Workspace nicht gefunden.',
'activated' => 'Konto aktiviert!',
'deactivated' => 'Konto deaktiviert!',
'already_connected' => 'Diese Plattform ist bereits verbunden.',
'no_youtube_channels' => 'Keine YouTube-Kanäle gefunden. Bitte erstelle zuerst einen Kanal.',
],
'popup_callback' => [
'title_success' => 'Verbunden',
'title_error' => 'Fehler',
'closing' => 'Dieses Fenster wird automatisch geschlossen...',
'manual_close' => 'Du kannst dieses Fenster schließen.',
'popup_blocked' => 'Das Verbindungsfenster konnte nicht geöffnet werden. Bitte erlaube Pop-ups und versuche es erneut.',
'connected' => 'Konto verbunden!',
'reconnected' => 'Konto erneut verbunden!',
'error_connecting' => 'Fehler beim Verbinden des Kontos. Bitte versuche es erneut.',
'network_taken' => 'Dieser Workspace hat bereits ein Konto für dieses Netzwerk. Trenne es zuerst.',
'error_connecting_page' => 'Fehler beim Verbinden der Seite. Bitte versuche es erneut.',
'error_connecting_channel' => 'Fehler beim Verbinden des Kanals. Bitte versuche es erneut.',
'session_expired' => 'Sitzung abgelaufen. Bitte versuche es erneut.',
'workspace_not_found' => 'Workspace nicht gefunden.',
'invalid_state' => 'Ungültiger Status. Bitte versuche es erneut.',
'failed_to_authenticate' => 'Authentifizierung fehlgeschlagen.',
'failed_to_get_profile' => 'Profil konnte nicht abgerufen werden.',
'page_not_found' => 'Seite nicht gefunden.',
'channel_not_found' => 'Kanal nicht gefunden.',
'no_facebook_pages' => 'Keine Facebook-Seiten gefunden. Du musst Administrator mindestens einer Seite sein.',
'no_facebook_instagram_pages' => 'Keine Facebook-Seiten mit verknüpften Instagram-Konten gefunden.',
'no_youtube_channels' => 'Keine YouTube-Kanäle gefunden. Bitte erstelle zuerst einen Kanal.',
'not_linkedin_admin' => 'Du bist kein Administrator einer LinkedIn-Seite.',
],
];

57
lang/de/analytics.php Normal file
View file

@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
return [
'no_accounts' => 'Keine verbundenen Konten mit Analysedaten.',
'no_accounts_match' => 'Keine passenden Konten.',
'search_account' => 'Konto suchen…',
'select_account' => 'Wähle ein Konto, um die Analysedaten anzuzeigen.',
'no_data' => 'Keine Analysedaten verfügbar.',
'metrics' => [
'avg_view_duration' => 'Durchschn. Wiedergabedauer (s)',
'avg_view_percentage' => 'Durchschn. Wiedergabeanteil',
'bookmarks' => 'Lesezeichen',
'clicks' => 'Klicks',
'comments' => 'Kommentare',
'custom_reaction' => 'Benutzerdefiniert',
'engagement' => 'Engagement',
'favourites' => 'Favoriten',
'followers' => 'Follower',
'following' => 'Abonniert',
'impressions' => 'Impressionen',
'interactions' => 'Interaktionen',
'likes' => 'Likes',
'members' => 'Mitglieder',
'minutes_watched' => 'Angesehene Minuten',
'organic_followers' => 'Organische Follower',
'outbound_clicks' => 'Ausgehende Klicks',
'page_followers' => 'Seiten-Follower',
'page_reach' => 'Seiten-Reichweite',
'page_views' => 'Seitenaufrufe',
'paid_followers' => 'Bezahlte Follower',
'pin_click_rate' => 'Pin-Klickrate',
'pin_clicks' => 'Pin-Klicks',
'posts_engagement' => 'Beitrags-Engagement',
'posts_reach' => 'Beitrags-Reichweite',
'quotes' => 'Zitate',
'reach' => 'Reichweite',
'reblogs' => 'Reblogs',
'recent_comments' => 'Neueste Kommentare',
'recent_likes' => 'Neueste Likes',
'recent_shares' => 'Neueste Shares',
'replies' => 'Antworten',
'reposts' => 'Reposts',
'retweets' => 'Retweets',
'saves' => 'Gespeichert',
'shares' => 'Shares',
'subscribers' => 'Abonnenten',
'subscribers_gained' => 'Gewonnene Abonnenten',
'subscribers_lost' => 'Verlorene Abonnenten',
'total_likes' => 'Likes gesamt',
'video_views' => 'Videoaufrufe',
'videos' => 'Videos',
'views' => 'Aufrufe',
],
];

54
lang/de/assets.php Normal file
View file

@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
return [
'title' => 'Assets',
'tabs' => [
'my_uploads' => 'Meine Uploads',
'stock_photos' => 'Stockfotos',
'gifs' => 'GIFs',
],
'upload' => [
'drag_drop' => 'Dateien hierher ziehen und ablegen oder zum Auswählen klicken',
'formats' => 'JPEG, PNG, GIF, WebP, MP4, PDF',
'uploading' => 'Wird hochgeladen...',
'failed' => ':file konnte nicht hochgeladen werden. Bitte versuche es erneut.',
],
'empty' => [
'title' => 'Noch keine Assets',
'description' => 'Lade Bilder und Videos hoch, um deine Medienbibliothek aufzubauen.',
],
'save_to_assets' => 'In Assets speichern',
'saved' => 'In deinen Assets gespeichert!',
'create_post' => 'Beitrag erstellen',
'add_to_post' => 'Zum Beitrag hinzufügen',
'search_placeholder' => 'Medien suchen...',
'delete' => [
'title' => 'Asset löschen',
'description' => 'Möchtest du dieses Asset wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.',
'confirm' => 'Löschen',
'cancel' => 'Abbrechen',
],
'unsplash' => [
'search_placeholder' => 'Kostenlose Fotos suchen...',
'no_results' => 'Keine Fotos gefunden',
'no_results_description' => 'Versuche einen anderen Suchbegriff.',
'trending' => 'Beliebt auf Unsplash',
'start_searching' => 'Suche nach kostenlosen Stockfotos von Unsplash',
],
'giphy' => [
'trending' => 'Beliebt auf Giphy',
'search_placeholder' => 'GIFs suchen...',
'no_results' => 'Keine GIFs gefunden',
'no_results_description' => 'Versuche einen anderen Suchbegriff.',
'powered_by' => 'Bereitgestellt von GIPHY',
],
];

143
lang/de/auth.php Normal file
View file

@ -0,0 +1,143 @@
<?php
declare(strict_types=1);
return [
/*
|--------------------------------------------------------------------------
| Authentication Language Lines
|--------------------------------------------------------------------------
|
| The following language lines are used during authentication for various
| messages that we need to display to the user. You are free to modify
| these language lines according to your application's requirements.
|
*/
'failed' => 'Diese Zugangsdaten stimmen nicht mit unseren Aufzeichnungen überein.',
'password' => 'Das angegebene Passwort ist falsch.',
'throttle' => 'Zu viele Anmeldeversuche. Bitte versuche es in :seconds Sekunden erneut.',
'flash' => [
'welcome' => 'Willkommen bei TryPost!',
'welcome_trial' => 'Willkommen bei TryPost! Deine Testphase hat begonnen.',
],
'legal' => 'Indem du fortfährst, stimmst du unseren <a href="https://trypost.it/terms" target="_blank">Nutzungsbedingungen</a> und unserer <a href="https://trypost.it/privacy" target="_blank">Datenschutzerklärung</a> zu.',
'slides' => [
'calendar' => [
'title' => 'Visueller Kalender',
'description' => 'Plane und terminiere deine Inhalte mit einem intuitiven Drag-and-drop-Kalender für alle deine Social-Media-Konten.',
],
'scheduling' => [
'title' => 'Intelligente Planung',
'description' => 'Plane Beiträge für LinkedIn, X, Instagram, TikTok, YouTube und mehr – alles von einem Ort aus.',
],
'media' => [
'title' => 'Rich Media',
'description' => 'Veröffentliche Bilder, Karussells, Storys und Reels. Jede Plattform erhält automatisch das richtige Format.',
],
'video' => [
'title' => 'Video-Veröffentlichung',
'description' => 'Lade Videos einmal hoch und veröffentliche sie auf TikTok, YouTube Shorts, Instagram Reels und Facebook Reels.',
],
'team' => [
'title' => 'Team-Workspaces',
'description' => 'Lade dein Team ein, weise Rollen zu und verwalte mehrere Marken in separaten Workspaces.',
],
'signatures' => [
'title' => 'Signaturen',
'description' => 'Speichere wiederverwendbare Signaturen (Hashtags, Links, Grußformeln) und füge sie mit einem Klick zu Beiträgen hinzu.',
],
],
'or_continue_with' => 'Oder fortfahren mit',
'or_continue_with_email' => 'Oder mit E-Mail fortfahren',
'google_login' => 'Mit Google anmelden',
'google_signup' => 'Mit Google registrieren',
'github_login' => 'Mit GitHub anmelden',
'github_signup' => 'Mit GitHub registrieren',
'github_email_unavailable' => 'Deine E-Mail-Adresse konnte nicht von GitHub abgerufen werden. Mache deine GitHub-E-Mail-Adresse öffentlich oder erteile die Berechtigung für den E-Mail-Zugriff und versuche es dann erneut.',
'signup_success' => [
'page_title' => 'Willkommen',
'title' => 'Dein Konto wird eingerichtet',
'description' => 'Das dauert normalerweise nur wenige Sekunden...',
],
'login' => [
'title' => 'Melde dich bei deinem Konto an',
'description' => 'Gib unten deine E-Mail-Adresse und dein Passwort ein, um dich anzumelden',
'page_title' => 'Anmelden',
'email' => 'E-Mail-Adresse',
'password' => 'Passwort',
'forgot_password' => 'Passwort vergessen?',
'remember_me' => 'Angemeldet bleiben',
'submit' => 'Anmelden',
'no_account' => 'Noch kein Konto?',
'sign_up' => 'Registrieren',
],
'register' => [
'title' => 'Dein gesamter Social-Media-Kalender an einem Ort',
'description' => 'Erstelle dein Konto und beginne, Beiträge für jedes Netzwerk zu planen.',
'page_title' => 'Registrieren',
'signup_with_email' => 'Mit E-Mail registrieren',
'name' => 'Name',
'name_placeholder' => 'Vollständiger Name',
'email' => 'E-Mail-Adresse',
'password' => 'Passwort',
'show_password' => 'Passwort anzeigen',
'hide_password' => 'Passwort verbergen',
'submit' => 'Konto erstellen',
'has_account' => 'Hast du bereits ein Konto?',
'log_in' => 'Anmelden',
],
'forgot_password' => [
'title' => 'Passwort vergessen',
'description' => 'Gib deine E-Mail-Adresse ein, um einen Link zum Zurücksetzen des Passworts zu erhalten',
'page_title' => 'Passwort vergessen',
'email' => 'E-Mail-Adresse',
'submit' => 'Link zum Zurücksetzen des Passworts senden',
'return_to' => 'Oder zurück zur',
'log_in' => 'Anmeldung',
],
'reset_password' => [
'title' => 'Passwort zurücksetzen',
'description' => 'Bitte gib unten dein neues Passwort ein',
'page_title' => 'Passwort zurücksetzen',
'email' => 'E-Mail',
'password' => 'Passwort',
'confirm_password' => 'Passwort bestätigen',
'confirm_placeholder' => 'Passwort bestätigen',
'submit' => 'Passwort zurücksetzen',
],
'verify_email' => [
'title' => 'E-Mail bestätigen',
'description' => 'Bitte bestätige deine E-Mail-Adresse, indem du auf den Link klickst, den wir dir gerade per E-Mail gesendet haben.',
'page_title' => 'E-Mail-Bestätigung',
'link_sent' => 'Ein neuer Bestätigungslink wurde an die E-Mail-Adresse gesendet, die du bei der Registrierung angegeben hast.',
'resend' => 'Bestätigungs-E-Mail erneut senden',
'log_out' => 'Abmelden',
],
'accept_invite' => [
'page_title' => 'Einladung annehmen',
'title' => 'Du wurdest eingeladen!',
'description' => 'Du wurdest eingeladen, dem Workspace :workspace beizutreten.',
'workspace' => 'Workspace',
'your_role' => 'Deine Rolle',
'email' => 'E-Mail',
'accept' => 'Einladung annehmen',
'decline' => 'Einladung ablehnen',
'login_prompt' => 'Melde dich an oder erstelle ein Konto, um diese Einladung anzunehmen.',
'log_in' => 'Anmelden',
'create_account' => 'Konto erstellen',
],
];

411
lang/de/automations.php Normal file
View file

@ -0,0 +1,411 @@
<?php
declare(strict_types=1);
return [
'title' => 'Automatisierungen',
'default_name' => 'Neue Automatisierung',
'actions' => [
'new' => 'Neue Automatisierung',
'edit' => 'Bearbeiten',
'save' => 'Speichern',
'activate' => 'Aktivieren',
'pause' => 'Pausieren',
'delete' => 'Löschen',
'retry' => 'Erneut versuchen',
'guide' => 'So funktioniert es',
],
'tabs' => [
'build' => 'Erstellen',
'variables' => 'Variablen',
'test' => 'Test',
],
'nav' => [
'workflow' => 'Workflow',
'invocations' => 'Ausführungen',
'metrics' => 'Kennzahlen',
'settings' => 'Einstellungen',
],
'settings' => [
'general' => 'Allgemein',
'general_description' => 'Benenne diese Automatisierung um.',
'name_label' => 'Name',
'name_saved' => 'Automatisierung umbenannt.',
'status_title' => 'Status',
'status_description' => 'Aktiviere sie, um sie auszuführen, oder pausiere sie, um sie zu stoppen.',
'activated_at' => 'Aktiviert :date',
'paused_at' => 'Pausiert :date',
'created_at' => 'Erstellt :date',
'danger_title' => 'Gefahrenzone',
'danger_description' => 'Unumkehrbare Aktionen.',
'delete_title' => 'Diese Automatisierung löschen',
'delete_description' => 'Entfernt die Automatisierung und ihren Ausführungsverlauf dauerhaft.',
],
'status_run' => [
'pending' => 'Ausstehend',
'running' => 'Läuft',
'waiting' => 'Wartet',
'completed' => 'Abgeschlossen',
'failed' => 'Fehlgeschlagen',
'cancelled' => 'Abgebrochen',
],
'node_type' => [
'trigger' => 'Trigger',
'generate' => 'Inhalt generieren',
'delay' => 'Verzögerung',
'condition' => 'Bedingung',
'publish' => 'Veröffentlichen',
'webhook' => 'Webhook',
'end' => 'Ende',
'fetch_rss' => 'RSS abrufen',
'http_request' => 'HTTP-Anfrage',
],
'invocations' => [
'empty' => 'Noch keine Ausführungen.',
'refresh' => 'Aktualisieren',
'search_placeholder' => 'Nach Ausführungs-ID suchen…',
'copied' => 'Ausführungs-ID kopiert.',
'loading' => 'Schritte werden geladen…',
'no_steps' => 'Keine Schritte aufgezeichnet.',
'load_error' => 'Schritte konnten nicht geladen werden.',
'steps' => '{0}Keine Schritte|{1}:count Schritt|[2,*]:count Schritte',
'filter' => [
'all' => 'Alle Status',
],
'columns' => [
'timestamp' => 'Zeitstempel',
'run' => 'Ausführung',
'status' => 'Status',
'message' => 'Letzte Meldung',
'duration' => 'Dauer',
],
'summary' => [
'completed' => 'Workflow abgeschlossen',
'failed' => 'Workflow fehlgeschlagen',
'running' => 'Workflow läuft',
'cancelled' => 'Workflow abgebrochen',
'pending' => 'Workflow ausstehend',
],
],
'metrics' => [
'overview' => 'Übersicht',
'runs_over_time' => 'Ausführungen im Zeitverlauf',
'posts_by_platform' => 'Beiträge nach Plattform',
'no_posts' => 'In diesem Zeitraum wurden keine Beiträge veröffentlicht.',
'cards' => [
'runs' => 'Ausführungen gesamt',
'completed' => 'Abgeschlossen',
'failed' => 'Fehlgeschlagen',
'in_progress' => 'In Bearbeitung',
'success_rate' => 'Erfolgsquote',
'avg_duration' => 'Durchschn. Dauer',
'posts_created' => 'Erstellte Beiträge',
],
'legend' => [
'started' => 'Gestartet',
'completed' => 'Abgeschlossen',
'failed' => 'Fehlgeschlagen',
],
],
'categories' => [
'sources' => 'Quellen',
'content' => 'Inhalt',
'flow' => 'Ablauf',
'output' => 'Ausgabe',
],
'variables' => [
'title' => 'Workflow-Variablen',
'hint' => 'Wiederverwendbare Werte, die überall mit {{ variables.KEY }} referenziert werden. Verschlüsselt gespeichert.',
'empty' => 'Noch keine Variablen.',
'key' => 'Schlüssel',
'value' => 'Wert',
'key_placeholder' => 'API_KEY',
'value_placeholder' => 'Wert',
'add' => 'Neue Variable',
],
'expr' => [
'trigger_event' => 'Name des Trigger-Events',
'trigger_fired_at' => 'Wann der Trigger ausgelöst wurde',
'trigger_post_id' => 'ID des auslösenden Beitrags',
'trigger_post_content' => 'Inhalt des auslösenden Beitrags',
'trigger_post_status' => 'Status des auslösenden Beitrags',
'trigger_post_scheduled_at' => 'Wann der Beitrag geplant ist',
'trigger_post_published_at' => 'Wann der Beitrag veröffentlicht wurde',
'fetched_title' => 'Titel des abgerufenen Eintrags',
'fetched_link' => 'Link des abgerufenen Eintrags',
'fetched_date' => 'Veröffentlichungsdatum des abgerufenen Eintrags',
'fetched_content' => 'Vollständiger Inhalt des abgerufenen Eintrags',
'fetched_description' => 'Zusammenfassung des abgerufenen Eintrags',
'fetched_author' => 'Autor des abgerufenen Eintrags',
'fetched_image' => 'Bild-URL des abgerufenen Eintrags',
'fetched_categories' => 'Kategorien des abgerufenen Eintrags',
'fetched_enclosure' => 'Medien des abgerufenen Eintrags (Audio/Video/Datei)',
'fetched_pubdate' => 'Veröffentlichungsdatum des abgerufenen Eintrags',
'fetched_http' => 'Abgerufener HTTP-Eintrag (Feld anhängen)',
'generated_content' => 'KI-generierter Beitragsinhalt',
'generated_post_url' => 'URL des KI-generierten Beitrags',
'variable' => 'Workflow-Variable',
'now' => 'Aktuelles Datum & Uhrzeit',
],
'test' => [
'description' => 'Führt die Automatisierung durchgängig mit einer synthetisierten Trigger-Nutzlast aus. Nützlich, um jeden Node zu validieren, ohne auf den echten Zeitplan oder Feed zu warten.',
'starting' => 'Testlauf wird gestartet…',
'in_progress' => 'In Bearbeitung',
'completed' => 'Abgeschlossen',
'failed' => 'Fehlgeschlagen',
'waiting' => 'Wartet',
'close' => 'Schließen',
'no_node_runs' => 'Warten auf den Start des ersten Nodes…',
'node_input' => 'Eingabe',
'node_output' => 'Ausgabe',
'node_error' => 'Fehler',
'no_new_items' => 'Keine neuen Einträge – nichts Nachgelagertes wurde ausgeführt.',
'error_starting' => 'Der Testlauf konnte nicht gestartet werden.',
'with_real_data' => 'Mit echten Daten',
'run' => 'Test ausführen',
'idle_hint' => 'Klicke auf „Test ausführen", um die Automatisierung durchgängig auszuführen.',
'real_data_hint' => 'Dieser Test veröffentlicht Beiträge, setzt Polling-Markierungen fort und löst externe Seiteneffekte aus.',
'dry_badge' => 'Probelauf',
],
'status' => [
'draft' => 'Entwurf',
'active' => 'Aktiv',
'paused' => 'Pausiert',
],
'index' => [
'empty_title' => 'Noch keine Automatisierungen',
'empty_description' => 'Erstelle deine erste Automatisierung, um auf Autopilot zu veröffentlichen.',
'columns' => [
'name' => 'Name',
'status' => 'Status',
'created' => 'Erstellt',
],
],
'form' => [
'activate_error_fallback' => 'Automatisierung konnte nicht aktiviert werden.',
'pause_error_fallback' => 'Automatisierung konnte nicht pausiert werden.',
'save_error_fallback' => 'Automatisierung konnte nicht gespeichert werden.',
'save_success' => 'Automatisierung gespeichert.',
'empty_canvas_title' => 'Beginne mit dem Aufbau deiner Automatisierung',
'empty_canvas_description' => 'Ziehe einen Node aus dem linken Bereich, um zu starten.',
'name_placeholder' => 'Unbenannte Automatisierung',
],
'nodes' => [
'trigger' => 'Trigger',
'generate' => 'Generieren',
'delay' => 'Verzögerung',
'condition' => 'Bedingung',
'publish' => 'Veröffentlichen',
'webhook' => 'Webhook',
'end' => 'Ende',
'end_summary' => 'Stoppt die Automatisierung hier',
'fetch_rss' => 'RSS abrufen',
'http_request' => 'HTTP-Anfrage',
'handles' => [
'items' => 'hat Einträge',
'no_items' => 'keine Einträge',
],
],
'config' => [
'select_placeholder' => 'Auswählen…',
'invalid_json' => 'Das ist noch kein gültiges JSON.',
'expand_editor' => 'Editor vergrößern',
'minimize_editor' => 'Verkleinern',
'trigger' => [
'type' => 'Trigger-Typ',
'types' => [
'schedule' => 'Zeitplan',
'post_published' => 'Wenn ein Beitrag veröffentlicht wird',
'post_scheduled' => 'Wenn ein Beitrag geplant wird',
],
'post_published_hint' => 'Läuft, wann immer ein Beitrag in diesem Workspace veröffentlicht wird. Der veröffentlichte Beitrag steht unter {{ trigger.post }} für nachgelagerte Nodes zur Verfügung.',
'post_scheduled_hint' => 'Läuft, wann immer ein Beitrag in diesem Workspace geplant wird. Der geplante Beitrag steht unter {{ trigger.post }} zur Verfügung.',
'schedule' => [
'field' => 'Trigger-Intervall',
'fields' => [
'minutes' => 'Minuten',
'hours' => 'Stunden',
'days' => 'Tage',
'weeks' => 'Wochen',
'months' => 'Monate',
],
'minutes_interval' => 'Minuten zwischen den Triggern',
'hours_interval' => 'Stunden zwischen den Triggern',
'days_interval' => 'Tage zwischen den Triggern',
'hour' => 'Auslösen zur Stunde',
'minute' => 'Auslösen zur Minute',
'weekdays' => 'An Wochentagen auslösen',
'day_of_month' => 'Tag des Monats',
'weekday_names' => [
'sun' => 'So',
'mon' => 'Mo',
'tue' => 'Di',
'wed' => 'Mi',
'thu' => 'Do',
'fri' => 'Fr',
'sat' => 'Sa',
],
'summary' => [
'every_n_minutes' => 'Läuft jede Minute|Läuft alle :count Minuten',
'every_n_hours' => 'Läuft jede Stunde zur Minute :minute|Läuft alle :count Stunden zur Minute :minute',
'every_n_days' => 'Läuft täglich um :time|Läuft alle :count Tage um :time',
'weekly' => 'Läuft :days um :time',
'monthly' => 'Läuft an Tag :day jedes Monats um :time',
],
],
],
'generate' => [
'social_accounts' => 'Social-Media-Konten',
'social_accounts_empty' => 'Keine verbundenen Social-Media-Konten. Verbinde zuerst eines.',
'target_slide_count' => 'Zu generierende Slides',
'prompt_template' => 'Prompt-Vorlage',
'prompt_template_hint' => 'Tippe {{, um Daten aus vorherigen Schritten einzufügen.',
'image_count' => 'Zu generierende Bilder',
'image_count_hint' => '0 = reiner Textbeitrag (kein Bild). 1 = einzelnes Bild. 2+ = Karussell.',
'use_brand_voice' => 'Markenton verwenden',
'use_brand_voice_hint' => 'Wende deine Markenbeschreibung und deinen Markenton an. Deaktiviere dies für die originalgetreue Kuratierung von Drittquellen (News, RSS).',
'use_brand_visuals' => 'Marken-Visuals verwenden',
'use_brand_visuals_hint' => 'Steuere KI-Bilder mit deinen Markenfarben und deiner Markenidentität. Deaktiviere dies für neutrale Bilder, die nur vom Beitragsthema bestimmt werden.',
'style' => 'Stil',
'account_summary' => ':count Konto · :format|:count Konten · :format',
'formats' => [
'single' => 'Einzeln',
'carousel' => 'Karussell',
],
],
'delay' => [
'duration' => 'Dauer',
'unit' => 'Einheit',
'units' => [
'minutes' => 'Minuten',
'hours' => 'Stunden',
'days' => 'Tage',
],
],
'condition' => [
'field' => 'Feld',
'operator' => 'Operator',
'operators' => [
'contains' => 'enthält',
'not_contains' => 'enthält nicht',
'equals' => 'ist gleich',
'not_equals' => 'ist ungleich',
'matches' => 'entspricht (Regex)',
'greater_than' => 'größer als',
'less_than' => 'kleiner als',
],
'value' => 'Wert',
],
'publish' => [
'mode' => 'Modus',
'modes' => [
'now' => 'Jetzt veröffentlichen',
'scheduled' => 'Planen',
'draft' => 'Als Entwurf speichern',
],
'scheduled_offset' => 'Versatz zum Trigger (Minuten)',
'offset_summary' => ':mode · +:offset Min.',
],
'webhook' => [
'url' => 'URL',
'method' => 'Methode',
'payload_template' => 'Payload-Vorlage (JSON)',
],
'end' => [
'reason' => 'Grund (optional)',
'reason_placeholder' => 'z. B. Durch Bedingung herausgefiltert',
],
'fetch_rss' => [
'feed_url' => 'Feed-URL',
'feed_url_hint' => 'Beim ersten Durchlauf wird die Markierung auf "now" gesetzt, damit historische Einträge die nachgelagerten Nodes nicht überfluten. Nachfolgende Durchläufe sehen nur Einträge, die neuer sind als die vorherige Abfrage.',
'inspect' => 'Feed prüfen',
'inspecting' => 'Wird geprüft…',
'inspect_hint' => 'Rufe ein Beispiel ab, um die verfügbaren Felder für nachgelagerte Nodes zu ermitteln.',
'inspect_error' => 'Dieser Feed konnte nicht gelesen werden. Prüfe die URL und versuche es erneut.',
'discovered_fields' => 'Verfügbare Felder',
'discovered_empty' => 'Keine Felder im neuesten Eintrag gefunden.',
],
'http_request' => [
'url' => 'URL',
'method' => 'Methode',
'auth_type' => 'Authentifizierung',
'auth' => [
'none' => 'Keine (öffentlich)',
'bearer' => 'Bearer-Token',
'basic' => 'Basic Auth',
'api_key' => 'API-Key-Header',
],
'bearer_token' => 'Bearer-Token',
'basic_username' => 'Benutzername',
'basic_password' => 'Passwort',
'api_key_header' => 'Header-Name',
'api_key_value' => 'API-Key',
'body_template' => 'Body-Vorlage (JSON)',
'headers' => 'Header',
'header_name' => 'Header-Name',
'header_value' => 'Wert',
'add_header' => 'Header hinzufügen',
'polling_section' => 'Liste & Deduplizierung (optional)',
'polling_hint' => 'Wenn die Antwort eine Liste ist, durchläuft jeder Eintrag den Workflow separat. Ein einzelnes Objekt wird einmal ausgeführt.',
'items_path' => 'Pfad zu den Einträgen',
'items_path_hint' => 'Leer lassen, wenn die Antwort bereits ein Array ist. Verwende einen Punkt-Pfad (z. B. data.items) für ein verschachteltes Array oder * für ein nach ID indiziertes Objekt.',
'item_key_path' => 'Pfad zum Eintragsschlüssel',
'item_key_path_hint' => 'JSON-Pfad zu einer eindeutigen ID (z. B. id). Bereits gesehene Einträge werden übersprungen, sodass ein Feed ohne Datumsangaben trotzdem nur neue Einträge weiterleitet.',
'item_date_path' => 'Pfad zum Eintragsdatum',
'item_date_path_hint' => 'JSON-Pfad zum Zeitstempel des Eintrags (z. B. published_at). Wird, sofern verfügbar, dem Schlüssel-Pfad vorgezogen. Die erste Abfrage erfasst den Ausgangswert und leitet nichts weiter, sodass ein bestehender Feed am ersten Tag niemals überflutet.',
],
],
'delete' => [
'title' => 'Automatisierung löschen',
'description' => 'Möchtest du diese Automatisierung wirklich löschen? Alle Ausführungen und Trigger-Einträge werden ebenfalls entfernt. Diese Aktion kann nicht rückgängig gemacht werden.',
'confirm' => 'Löschen',
'cancel' => 'Abbrechen',
],
'flash' => [
'deleted' => 'Automatisierung erfolgreich gelöscht!',
],
'errors' => [
'no_active_social_accounts' => 'Für diese Automatisierung sind keine aktiven Social-Media-Konten konfiguriert.',
'must_have_one_trigger' => 'Eine Automatisierung muss genau einen Trigger-Node haben.',
'trigger_must_be_connected' => 'Der Trigger-Node muss mit mindestens einem Node verbunden sein.',
'graph_contains_cycle' => 'Der Automatisierungsgraph enthält einen Zyklus.',
'only_failed_can_retry' => 'Nur fehlgeschlagene Ausführungen können wiederholt werden.',
'no_generated_post' => 'Bei der Ausführung wurde kein generierter Beitrag gefunden.',
'webhook_server_error' => 'Webhook-Serverfehler.',
'webhook_request_failed' => 'Die Webhook-Anfrage konnte nicht abgeschlossen werden.',
'webhook_missing_url' => 'Dem Webhook-Node fehlt eine URL.',
'webhook_invalid_payload_json' => 'Die Payload-Vorlage ist kein gültiges JSON.',
'url_not_allowed' => 'Die Anfrage-URL verweist auf eine private oder nicht erreichbare Adresse und wurde blockiert.',
'node_no_longer_exists' => 'Node :node_id existiert in der Automatisierung nicht mehr.',
'no_trigger_connection' => 'Kein Node mit dem Trigger-Node verbunden.',
'fetch_rss_missing_url' => 'Dem Node „RSS abrufen" fehlt eine Feed-URL.',
'fetch_rss_request_failed' => 'Die Anfrage an den RSS-Feed ist fehlgeschlagen.',
'fetch_rss_malformed' => 'Der RSS-Feed ist fehlerhaft.',
'http_missing_url' => 'Dem HTTP-Anfrage-Node fehlt eine URL.',
'http_request_exception' => 'Die HTTP-Anfrage hat eine Ausnahme ausgelöst.',
'http_request_failed' => 'Die HTTP-Anfrage ist fehlgeschlagen.',
'http_items_path_not_array' => 'Der Pfad zu den Einträgen ergab keine Liste.',
],
];

78
lang/de/billing.php Normal file
View file

@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
return [
'title' => 'Abrechnung',
'past_due_notice' => [
'title' => 'Zahlung überfällig',
'description' => 'Aktualisiere deine Zahlungsmethode, um dein Abonnement aktiv zu halten.',
'cta' => 'Zahlung aktualisieren',
],
'annual_banner' => [
'title' => '2 Monate gratis erhalten',
'description' => 'Wechsle zur jährlichen Abrechnung und zahle jeden Monat weniger – gleicher Tarif, sonst ändert sich nichts.',
'cta' => 'Auf jährlich upgraden',
],
'subscribe' => [
'billed_monthly' => 'Monatlich abgerechnet',
'billed_yearly' => 'Jährlich abgerechnet',
'prices' => [
'workspace' => ['monthly' => '$12', 'yearly_per_month' => '$10', 'yearly' => '$120'],
],
],
'plan' => [
'title' => 'Tarif',
'description' => 'Verwalte deinen Abonnement-Tarif.',
'label' => 'Tarif',
'workspaces' => '{1}:count Workspace|[2,*]:count Workspaces',
'per_workspace' => 'pro Workspace',
'price' => 'Preis',
'month' => 'Monat',
'trial' => 'Testphase',
'active' => 'Aktiv',
'past_due' => 'Überfällig',
'cancelling' => 'Wird gekündigt',
'trial_ends' => 'Testphase endet',
],
'subscription' => [
'title' => 'Abonnement',
'description' => 'Verwalte deine Zahlungsmethode, Rechnungsdaten und dein Abonnement.',
'payment_method' => 'Zahlungsmethode',
'no_payment_method' => 'Noch keine Zahlungsmethode hinterlegt.',
'expires_on' => 'Läuft ab :month/:year',
'manage_label' => 'Abonnement',
'manage_stripe' => 'Bei Stripe verwalten',
],
'invoices' => [
'title' => 'Rechnungen',
'description' => 'Lade deine bisherigen Rechnungen herunter.',
'empty' => 'Keine Rechnungen gefunden',
'paid' => 'Bezahlt',
],
'flash' => [
'plan_changed' => 'Du nutzt jetzt den Tarif :plan.',
'switched_to_yearly' => 'Du nutzt jetzt die jährliche Abrechnung.',
'cannot_manage' => 'Nur der Kontoinhaber kann die Abrechnung verwalten.',
'credits_exhausted' => 'Keine KI-Credits mehr – dein monatliches Kontingent von :limit ist aufgebraucht. Führe ein Upgrade durch oder warte bis zum nächsten Monat.',
'subscription_required' => 'Für die Nutzung der KI-Funktionen ist ein aktives Abonnement erforderlich.',
],
'processing' => [
'page_title' => 'Wird verarbeitet...',
'title' => 'Dein Abonnement wird verarbeitet',
'description' => 'Bitte warte, während wir dein Konto einrichten. Das dauert nur einen Moment.',
'success_title' => 'Alles bereit!',
'success_description' => 'Dein Abonnement ist aktiv. Du wirst zu deinen Workspaces weitergeleitet...',
'cancelled_title' => 'Bezahlvorgang abgebrochen',
'cancelled_description' => 'Dein Bezahlvorgang wurde abgebrochen. Es wurden keine Kosten berechnet.',
'retry' => 'Erneut versuchen',
],
];

41
lang/de/brands.php Normal file
View file

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
return [
'new_brand' => 'Neue Marke',
'no_brands_yet' => 'Noch keine Marken',
'no_brands_description' => 'Erstelle Marken, um deine Social-Media-Konten nach Kunde oder Projekt zu organisieren',
'accounts_count' => ':count Konten',
'create' => [
'title' => 'Marke erstellen',
'description' => 'Gib deiner Marke einen Namen, um Social-Media-Konten zu gruppieren',
'name' => 'Markenname',
'name_placeholder' => 'z. B. Acme Corp, Privat',
'submit' => 'Marke erstellen',
'submitting' => 'Wird erstellt...',
],
'edit' => [
'title' => 'Marke bearbeiten',
'description' => 'Aktualisiere den Namen dieser Marke',
'name' => 'Markenname',
'name_placeholder' => 'z. B. Acme Corp, Privat',
'submit' => 'Änderungen speichern',
'submitting' => 'Wird gespeichert...',
],
'delete' => [
'title' => 'Marke löschen',
'description' => 'Möchtest du diese Marke wirklich löschen? Die Zuordnung der Social-Media-Konten wird aufgehoben, sie werden aber nicht gelöscht.',
'confirm' => 'Löschen',
'cancel' => 'Abbrechen',
],
'flash' => [
'created' => 'Marke erfolgreich erstellt!',
'updated' => 'Marke erfolgreich aktualisiert!',
'deleted' => 'Marke erfolgreich gelöscht!',
],
];

14
lang/de/calendar.php Normal file
View file

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
return [
'title' => 'Kalender',
'today' => 'Heute',
'day' => 'Tag',
'week' => 'Woche',
'month' => 'Monat',
'new_post' => 'Neuer Beitrag',
'no_content' => 'Kein Inhalt',
'more' => '+:count weitere',
];

20
lang/de/comments.php Normal file
View file

@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
return [
'placeholder' => 'Schreibe einen Kommentar...',
'reply_placeholder' => 'Schreibe eine Antwort...',
'reply' => 'Antworten',
'edit' => 'Bearbeiten',
'delete' => 'Löschen',
'edited' => 'bearbeitet',
'save' => 'Speichern',
'cancel' => 'Abbrechen',
'send' => 'Senden',
'replying_to' => 'Antwort an :name',
'empty' => 'Noch keine Kommentare. Starte die Unterhaltung.',
'load_more' => 'Ältere Kommentare laden',
'today' => 'Heute',
'yesterday' => 'Gestern',
];

67
lang/de/common.php Normal file
View file

@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
return [
'back' => 'Zurück',
'beta' => 'Beta',
'confirm_modal' => [
'cannot_be_undone' => 'Dies kann nicht rückgängig gemacht werden.',
'type' => 'Gib',
'to_confirm' => 'zur Bestätigung ein.',
'copy_to_clipboard' => 'In die Zwischenablage kopieren',
'delete_keyword' => 'löschen',
],
'photo_upload' => [
'upload' => 'Hochladen',
'uploading' => 'Wird hochgeladen...',
'remove' => 'Foto entfernen',
'hint' => 'Empfohlen: quadratisches Bild, max. 2 MB.',
'crop_title' => 'Bild zuschneiden',
'crop_description' => 'Wählen Sie den Bereich aus, den Sie behalten möchten.',
'crop_hint' => 'Zum Verschieben ziehen oder an einer Ecke die Größe ändern.',
'crop_save' => 'Speichern',
'crop_cancel' => 'Abbrechen',
'crop_error' => 'Dieses Bild konnte nicht geladen werden. Versuchen Sie eine andere Datei.',
],
'timezone' => [
'select' => 'Zeitzone auswählen',
'search' => 'Zeitzone suchen...',
'empty' => 'Keine Zeitzone gefunden',
],
'date_picker' => [
'select' => 'Datum auswählen',
],
'date_range_picker' => [
'placeholder' => 'Zeitraum wählen',
'today' => 'Heute',
'yesterday' => 'Gestern',
'last_7_days' => 'Letzte 7 Tage',
'last_30_days' => 'Letzte 30 Tage',
'last_3_months' => 'Letzte 3 Monate',
'last_6_months' => 'Letzte 6 Monate',
'last_12_months' => 'Letzte 12 Monate',
'this_month' => 'Dieser Monat',
'last_month' => 'Letzter Monat',
'year_to_date' => 'Seit Jahresbeginn',
'last_year' => 'Letztes Jahr',
],
'cancel' => 'Abbrechen',
'clear' => 'Zurücksetzen',
'close' => 'Schließen',
'loading_more' => 'Weitere werden geladen...',
'actions' => [
'copy' => 'Kopieren',
'copied' => 'Kopiert',
'copy_failed' => 'Kopieren in die Zwischenablage fehlgeschlagen',
],
];

56
lang/de/labels.php Normal file
View file

@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
return [
'title' => 'Labels',
'description' => 'Erstelle Labels, um deine Beiträge zu organisieren und zu kategorisieren',
'search' => 'Labels suchen...',
'new_label' => 'Neues Label',
'no_labels_yet' => 'Noch keine Labels',
'no_search_results' => 'Keine Labels passen zu deiner Suche',
'try_different_search' => 'Versuche ein anderes Stichwort oder setze die Suche zurück.',
'create_first_label' => 'Erstelle dein erstes Label',
'table' => [
'name' => 'Name',
'created_at' => 'Erstellt',
],
'actions' => [
'edit' => 'Label bearbeiten',
'delete' => 'Label löschen',
],
'create' => [
'title' => 'Label erstellen',
'description' => 'Gib deinem Label einen Namen und wähle eine Farbe',
'name' => 'Name',
'name_placeholder' => 'Label-Namen eingeben...',
'color' => 'Farbe',
'submit' => 'Label erstellen',
'submitting' => 'Wird erstellt...',
],
'edit' => [
'title' => 'Label bearbeiten',
'description' => 'Aktualisiere Name und Farbe für dieses Label',
'name' => 'Name',
'name_placeholder' => 'Label-Namen eingeben...',
'color' => 'Farbe',
'submit' => 'Änderungen speichern',
'submitting' => 'Wird gespeichert...',
],
'delete' => [
'title' => 'Label löschen',
'description' => 'Möchtest du dieses Label wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.',
'confirm' => 'Löschen',
'cancel' => 'Abbrechen',
],
'flash' => [
'created' => 'Label erfolgreich erstellt!',
'updated' => 'Label erfolgreich aktualisiert!',
'deleted' => 'Label erfolgreich gelöscht!',
],
];

24
lang/de/mail.php Normal file
View file

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
return [
'mentioned' => [
'subject' => ':name hat dich auf TryPost erwähnt',
'title' => ':name hat dich erwähnt',
'intro' => ':name hat dich in einem Beitragskommentar erwähnt.',
'cta' => 'Kommentar ansehen',
],
'workspace_connections_disconnected' => [
'subject' => '{1} :count Konto muss in :workspace erneut verbunden werden|[2,*] :count Konten müssen in :workspace erneut verbunden werden',
'title' => 'Konten müssen erneut verbunden werden',
'intro' => 'Die folgenden Social-Media-Konten in deinem Workspace <strong>:workspace</strong> wurden getrennt und müssen erneut verbunden werden:',
'reasons_title' => 'Das kann folgende Gründe haben:',
'reason_expired' => 'Zugriffstokens sind abgelaufen',
'reason_revoked' => 'Du hast den Zugriff von TryPost auf der Plattform widerrufen',
'reason_changed' => 'Die Plattform hat ihre Authentifizierungsanforderungen geändert',
'reconnect_cta' => 'Bitte verbinde diese Konten erneut, um weiterhin Beiträge zu planen und zu veröffentlichen.',
'button' => 'Konten erneut verbinden',
],
];

18
lang/de/notifications.php Normal file
View file

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
return [
'post_ready' => [
'title' => 'Dein Beitrag ist fertig',
'body' => 'Die KI ist gerade fertig geworden. Tippe, um ihn zu prüfen und zu veröffentlichen.',
],
'account_disconnected' => [
'title' => ':platform-Konto getrennt',
'body' => ':account muss erneut verbunden werden',
],
'account_token_expired' => [
'title' => ':platform-Konto muss erneut verbunden werden',
'body' => 'Sitzung von :account abgelaufen – bitte verbinde es erneut, um weiter zu posten',
],
];

41
lang/de/onboarding.php Normal file
View file

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
return [
'title' => 'Willkommen bei TryPost',
'description' => 'Sag uns, was dich oder dein Unternehmen am besten beschreibt, damit wir dein Erlebnis anpassen können.',
'continue' => 'Weiter',
'personas' => [
'creator' => 'Content Creator',
'freelancer' => 'Freelancer',
'developer' => 'Entwickler',
'startup' => 'Startup',
'agency' => 'Agentur',
'small_business' => 'Kleinunternehmen',
'marketer' => 'Marketer',
'online_store' => 'Onlineshop',
'other' => 'Sonstiges',
],
'goals_title' => 'Was ist dein Ziel mit TryPost?',
'goals_description' => 'Wähle alles aus, was passt, und wir richten TryPost für dich ein.',
'goals' => [
'save_time' => 'Zeit sparen, indem ich überall gleichzeitig poste',
'ai_content' => 'Beiträge schneller mit KI erstellen',
'plan_calendar' => 'Meine Beiträge in einem Kalender planen',
'stay_on_brand' => 'Jeden Beitrag markenkonform halten',
'grow_audience' => 'Meine Reichweite und mein Engagement steigern',
'drive_sales' => 'Mehr Traffic und Verkäufe erzielen',
'manage_clients' => 'Mehrere Marken oder Kunden verwalten',
'team_collaboration' => 'Mit meinem Team arbeiten',
'automate_api' => 'Das Posten per API, MCP oder Code automatisieren',
'track_performance' => 'Sehen, wie meine Beiträge performen',
'just_exploring' => 'Ich schaue mich vorerst nur um',
'other' => 'Etwas anderes',
],
'connect' => [
'title' => 'Verbinde dein erstes Netzwerk',
'description' => 'Verknüpfe mindestens ein Social-Media-Konto, um mit der Planung zu beginnen. Du kannst jederzeit weitere hinzufügen.',
'must_connect' => 'Verbinde mindestens ein Netzwerk, um fortzufahren.',
],
];

21
lang/de/pagination.php Normal file
View file

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
return [
/*
|--------------------------------------------------------------------------
| Pagination Language Lines
|--------------------------------------------------------------------------
|
| The following language lines are used by the paginator library to build
| the simple pagination links. You are free to change them to anything
| you want to customize your views to better match your application.
|
*/
'previous' => '&laquo; Zurück',
'next' => 'Weiter &raquo;',
];

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