Merge branch 'main' into feat/ai-prompt-char-counter
This commit is contained in:
commit
a9628b7e8d
426 changed files with 39128 additions and 445 deletions
|
|
@ -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=
|
||||
|
|
|
|||
65
.github/actions/setup-laravel/action.yml
vendored
Normal file
65
.github/actions/setup-laravel/action.yml
vendored
Normal 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
|
||||
2
.github/workflows/lint.yml
vendored
2
.github/workflows/lint.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
6
.github/workflows/release-docker.yml
vendored
6
.github/workflows/release-docker.yml
vendored
|
|
@ -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-*
|
||||
|
|
|
|||
132
.github/workflows/tests.yml
vendored
132
.github/workflows/tests.yml
vendored
|
|
@ -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
1
.gitignore
vendored
|
|
@ -1,6 +1,7 @@
|
|||
/.phpunit.cache
|
||||
/bootstrap/ssr
|
||||
/node_modules
|
||||
/tests/Browser/Screenshots
|
||||
/public/build
|
||||
/lang/php_*.json
|
||||
/public/hot
|
||||
|
|
|
|||
|
|
@ -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}',
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
146
app/Enums/Workspace/ContentLanguage.php
Normal file
146
app/Enums/Workspace/ContentLanguage.php
Normal 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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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).'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ class GiphyService
|
|||
|
||||
public function __construct()
|
||||
{
|
||||
$this->apiKey = config('services.giphy.api_key', '');
|
||||
$this->apiKey = (string) config('services.giphy.api_key', '');
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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', [
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
30
app/Services/Social/Meta/GraphError.php
Normal file
30
app/Services/Social/Meta/GraphError.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ class UnsplashService
|
|||
|
||||
public function __construct()
|
||||
{
|
||||
$this->accessKey = config('services.unsplash.access_key', '');
|
||||
$this->accessKey = (string) config('services.unsplash.access_key', '');
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
66
app/Support/Billing/FirstMonthCheckoutDiscount.php
Normal file
66
app/Support/Billing/FirstMonthCheckoutDiscount.php
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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'],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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
1573
composer.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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'),
|
||||
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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
148
lang/ar/accounts.php
Normal 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
55
lang/ar/analytics.php
Normal 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
52
lang/ar/assets.php
Normal 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
141
lang/ar/auth.php
Normal 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
411
lang/ar/automations.php
Normal 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
76
lang/ar/billing.php
Normal 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
39
lang/ar/brands.php
Normal 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
12
lang/ar/calendar.php
Normal 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
18
lang/ar/comments.php
Normal 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
67
lang/ar/common.php
Normal 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
54
lang/ar/labels.php
Normal 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
22
lang/ar/mail.php
Normal 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
18
lang/ar/notifications.php
Normal 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
41
lang/ar/onboarding.php
Normal 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
19
lang/ar/pagination.php
Normal 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' => '« السابق',
|
||||
'next' => 'التالي »',
|
||||
|
||||
];
|
||||
22
lang/ar/passwords.php
Normal file
22
lang/ar/passwords.php
Normal 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
679
lang/ar/posts.php
Normal 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
363
lang/ar/settings.php
Normal 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
61
lang/ar/sidebar.php
Normal 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
59
lang/ar/signatures.php
Normal 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
15
lang/ar/usage.php
Normal 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
200
lang/ar/validation.php
Normal 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
50
lang/ar/workspaces.php
Normal 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
150
lang/de/accounts.php
Normal 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
57
lang/de/analytics.php
Normal 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
54
lang/de/assets.php
Normal 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
143
lang/de/auth.php
Normal 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
411
lang/de/automations.php
Normal 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
78
lang/de/billing.php
Normal 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
41
lang/de/brands.php
Normal 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
14
lang/de/calendar.php
Normal 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
20
lang/de/comments.php
Normal 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
67
lang/de/common.php
Normal 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
56
lang/de/labels.php
Normal 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
24
lang/de/mail.php
Normal 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
18
lang/de/notifications.php
Normal 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
41
lang/de/onboarding.php
Normal 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
21
lang/de/pagination.php
Normal 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' => '« Zurück',
|
||||
'next' => 'Weiter »',
|
||||
|
||||
];
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue