feat: improvements on ui
This commit is contained in:
parent
925540b93e
commit
9f67f4042d
37 changed files with 4174 additions and 2625 deletions
|
|
@ -29,6 +29,7 @@ public function store(StoreMediaRequest $request): JsonResponse
|
|||
|
||||
return response()->json([
|
||||
'id' => $media->id,
|
||||
'group_id' => $media->group_id,
|
||||
'url' => $media->url,
|
||||
'type' => $media->type->value,
|
||||
'original_filename' => $media->original_filename,
|
||||
|
|
@ -71,6 +72,7 @@ public function storeChunked(Request $request): JsonResponse
|
|||
return response()->json([
|
||||
'done' => true,
|
||||
'id' => $media->id,
|
||||
'group_id' => $media->group_id,
|
||||
'url' => $media->url,
|
||||
'type' => $media->type->value,
|
||||
'original_filename' => $media->original_filename,
|
||||
|
|
@ -96,6 +98,21 @@ public function destroy(string $modelId, Media $media): JsonResponse
|
|||
return response()->json(['success' => true]);
|
||||
}
|
||||
|
||||
public function reorder(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'media' => 'required|array',
|
||||
'media.*.id' => 'required|exists:medias,id',
|
||||
'media.*.order' => 'required|integer|min:0',
|
||||
]);
|
||||
|
||||
foreach ($request->input('media') as $item) {
|
||||
Media::where('id', $item['id'])->update(['order' => $item['order']]);
|
||||
}
|
||||
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
|
||||
public function duplicate(Media $media, Request $request): JsonResponse
|
||||
{
|
||||
$targets = $request->input('targets', []);
|
||||
|
|
@ -111,6 +128,7 @@ public function duplicate(Media $media, Request $request): JsonResponse
|
|||
$model = $modelClass::findOrFail($modelId);
|
||||
|
||||
$duplicate = $model->media()->create([
|
||||
'group_id' => $media->group_id,
|
||||
'collection' => $collection,
|
||||
'type' => $media->type,
|
||||
'path' => $media->path,
|
||||
|
|
@ -123,6 +141,7 @@ public function duplicate(Media $media, Request $request): JsonResponse
|
|||
|
||||
$duplicates[] = [
|
||||
'id' => $duplicate->id,
|
||||
'group_id' => $duplicate->group_id,
|
||||
'mediable_id' => $duplicate->mediable_id,
|
||||
'mediable_type' => $duplicate->mediable_type,
|
||||
'url' => $duplicate->url,
|
||||
|
|
|
|||
|
|
@ -8,9 +8,9 @@
|
|||
class StoreMediaRequest extends FormRequest
|
||||
{
|
||||
protected array $allowedModels = [
|
||||
'App\Models\PostPlatform',
|
||||
'App\Models\Workspace',
|
||||
'App\Models\User',
|
||||
'postPlatform',
|
||||
'workspace',
|
||||
'user',
|
||||
];
|
||||
|
||||
public function authorize(): bool
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ class Media extends Model
|
|||
protected $fillable = [
|
||||
'mediable_id',
|
||||
'mediable_type',
|
||||
'group_id',
|
||||
'collection',
|
||||
'type',
|
||||
'path',
|
||||
|
|
@ -64,7 +65,14 @@ public function getTemporaryUrl(int $expirationMinutes = 60): string
|
|||
|
||||
public function delete(): bool
|
||||
{
|
||||
Storage::delete($this->path);
|
||||
// Only delete the file if no other media records use the same path
|
||||
$otherMediaWithSamePath = static::where('path', $this->path)
|
||||
->where('id', '!=', $this->id)
|
||||
->exists();
|
||||
|
||||
if (! $otherMediaWithSamePath) {
|
||||
Storage::delete($this->path);
|
||||
}
|
||||
|
||||
return parent::delete();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ public function getFallbackAvatarUrl(string $seed): string
|
|||
* Add media to a collection.
|
||||
* If the collection is configured as 'single', it will clear existing media first.
|
||||
*/
|
||||
public function addMedia(UploadedFile $file, string $collection = 'default', array $meta = []): Media
|
||||
public function addMedia(UploadedFile $file, string $collection = 'default', array $meta = [], ?string $groupId = null): Media
|
||||
{
|
||||
if ($this->isSingleMediaCollection($collection)) {
|
||||
$this->clearMediaCollection($collection);
|
||||
|
|
@ -80,6 +80,7 @@ public function addMedia(UploadedFile $file, string $collection = 'default', arr
|
|||
Storage::put($path, file_get_contents($file->getPathname()));
|
||||
|
||||
return $this->media()->create([
|
||||
'group_id' => $groupId ?? Str::uuid()->toString(),
|
||||
'collection' => $collection,
|
||||
'type' => $type,
|
||||
'path' => $path,
|
||||
|
|
@ -94,7 +95,7 @@ public function addMedia(UploadedFile $file, string $collection = 'default', arr
|
|||
/**
|
||||
* Add media from a file path (used for chunked uploads).
|
||||
*/
|
||||
public function addMediaFromPath(string $filePath, string $originalFilename, string $collection = 'default', array $meta = []): Media
|
||||
public function addMediaFromPath(string $filePath, string $originalFilename, string $collection = 'default', array $meta = [], ?string $groupId = null): Media
|
||||
{
|
||||
if ($this->isSingleMediaCollection($collection)) {
|
||||
$this->clearMediaCollection($collection);
|
||||
|
|
@ -120,6 +121,7 @@ public function addMediaFromPath(string $filePath, string $originalFilename, str
|
|||
}
|
||||
|
||||
return $this->media()->create([
|
||||
'group_id' => $groupId ?? Str::uuid()->toString(),
|
||||
'collection' => $collection,
|
||||
'type' => $type,
|
||||
'path' => $storagePath,
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ public function up(): void
|
|||
{
|
||||
Schema::create('medias', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
$table->uuid('group_id')->nullable()->index();
|
||||
$table->uuidMorphs('mediable');
|
||||
$table->string('collection')->default('default');
|
||||
$table->string('type');
|
||||
|
|
|
|||
20
lang/en/auth.php
Normal file
20
lang/en/auth.php
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<?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' => 'These credentials do not match our records.',
|
||||
'password' => 'The provided password is incorrect.',
|
||||
'throttle' => 'Too many login attempts. Please try again in :seconds seconds.',
|
||||
|
||||
];
|
||||
19
lang/en/pagination.php
Normal file
19
lang/en/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' => '« Previous',
|
||||
'next' => 'Next »',
|
||||
|
||||
];
|
||||
22
lang/en/passwords.php
Normal file
22
lang/en/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' => 'Your password has been reset.',
|
||||
'sent' => 'We have emailed your password reset link.',
|
||||
'throttled' => 'Please wait before retrying.',
|
||||
'token' => 'This password reset token is invalid.',
|
||||
'user' => "We can't find a user with that email address.",
|
||||
|
||||
];
|
||||
200
lang/en/validation.php
Normal file
200
lang/en/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' => 'The :attribute field must be accepted.',
|
||||
'accepted_if' => 'The :attribute field must be accepted when :other is :value.',
|
||||
'active_url' => 'The :attribute field must be a valid URL.',
|
||||
'after' => 'The :attribute field must be a date after :date.',
|
||||
'after_or_equal' => 'The :attribute field must be a date after or equal to :date.',
|
||||
'alpha' => 'The :attribute field must only contain letters.',
|
||||
'alpha_dash' => 'The :attribute field must only contain letters, numbers, dashes, and underscores.',
|
||||
'alpha_num' => 'The :attribute field must only contain letters and numbers.',
|
||||
'any_of' => 'The :attribute field is invalid.',
|
||||
'array' => 'The :attribute field must be an array.',
|
||||
'ascii' => 'The :attribute field must only contain single-byte alphanumeric characters and symbols.',
|
||||
'before' => 'The :attribute field must be a date before :date.',
|
||||
'before_or_equal' => 'The :attribute field must be a date before or equal to :date.',
|
||||
'between' => [
|
||||
'array' => 'The :attribute field must have between :min and :max items.',
|
||||
'file' => 'The :attribute field must be between :min and :max kilobytes.',
|
||||
'numeric' => 'The :attribute field must be between :min and :max.',
|
||||
'string' => 'The :attribute field must be between :min and :max characters.',
|
||||
],
|
||||
'boolean' => 'The :attribute field must be true or false.',
|
||||
'can' => 'The :attribute field contains an unauthorized value.',
|
||||
'confirmed' => 'The :attribute field confirmation does not match.',
|
||||
'contains' => 'The :attribute field is missing a required value.',
|
||||
'current_password' => 'The password is incorrect.',
|
||||
'date' => 'The :attribute field must be a valid date.',
|
||||
'date_equals' => 'The :attribute field must be a date equal to :date.',
|
||||
'date_format' => 'The :attribute field must match the format :format.',
|
||||
'decimal' => 'The :attribute field must have :decimal decimal places.',
|
||||
'declined' => 'The :attribute field must be declined.',
|
||||
'declined_if' => 'The :attribute field must be declined when :other is :value.',
|
||||
'different' => 'The :attribute field and :other must be different.',
|
||||
'digits' => 'The :attribute field must be :digits digits.',
|
||||
'digits_between' => 'The :attribute field must be between :min and :max digits.',
|
||||
'dimensions' => 'The :attribute field has invalid image dimensions.',
|
||||
'distinct' => 'The :attribute field has a duplicate value.',
|
||||
'doesnt_contain' => 'The :attribute field must not contain any of the following: :values.',
|
||||
'doesnt_end_with' => 'The :attribute field must not end with one of the following: :values.',
|
||||
'doesnt_start_with' => 'The :attribute field must not start with one of the following: :values.',
|
||||
'email' => 'The :attribute field must be a valid email address.',
|
||||
'encoding' => 'The :attribute field must be encoded in :encoding.',
|
||||
'ends_with' => 'The :attribute field must end with one of the following: :values.',
|
||||
'enum' => 'The selected :attribute is invalid.',
|
||||
'exists' => 'The selected :attribute is invalid.',
|
||||
'extensions' => 'The :attribute field must have one of the following extensions: :values.',
|
||||
'file' => 'The :attribute field must be a file.',
|
||||
'filled' => 'The :attribute field must have a value.',
|
||||
'gt' => [
|
||||
'array' => 'The :attribute field must have more than :value items.',
|
||||
'file' => 'The :attribute field must be greater than :value kilobytes.',
|
||||
'numeric' => 'The :attribute field must be greater than :value.',
|
||||
'string' => 'The :attribute field must be greater than :value characters.',
|
||||
],
|
||||
'gte' => [
|
||||
'array' => 'The :attribute field must have :value items or more.',
|
||||
'file' => 'The :attribute field must be greater than or equal to :value kilobytes.',
|
||||
'numeric' => 'The :attribute field must be greater than or equal to :value.',
|
||||
'string' => 'The :attribute field must be greater than or equal to :value characters.',
|
||||
],
|
||||
'hex_color' => 'The :attribute field must be a valid hexadecimal color.',
|
||||
'image' => 'The :attribute field must be an image.',
|
||||
'in' => 'The selected :attribute is invalid.',
|
||||
'in_array' => 'The :attribute field must exist in :other.',
|
||||
'in_array_keys' => 'The :attribute field must contain at least one of the following keys: :values.',
|
||||
'integer' => 'The :attribute field must be an integer.',
|
||||
'ip' => 'The :attribute field must be a valid IP address.',
|
||||
'ipv4' => 'The :attribute field must be a valid IPv4 address.',
|
||||
'ipv6' => 'The :attribute field must be a valid IPv6 address.',
|
||||
'json' => 'The :attribute field must be a valid JSON string.',
|
||||
'list' => 'The :attribute field must be a list.',
|
||||
'lowercase' => 'The :attribute field must be lowercase.',
|
||||
'lt' => [
|
||||
'array' => 'The :attribute field must have less than :value items.',
|
||||
'file' => 'The :attribute field must be less than :value kilobytes.',
|
||||
'numeric' => 'The :attribute field must be less than :value.',
|
||||
'string' => 'The :attribute field must be less than :value characters.',
|
||||
],
|
||||
'lte' => [
|
||||
'array' => 'The :attribute field must not have more than :value items.',
|
||||
'file' => 'The :attribute field must be less than or equal to :value kilobytes.',
|
||||
'numeric' => 'The :attribute field must be less than or equal to :value.',
|
||||
'string' => 'The :attribute field must be less than or equal to :value characters.',
|
||||
],
|
||||
'mac_address' => 'The :attribute field must be a valid MAC address.',
|
||||
'max' => [
|
||||
'array' => 'The :attribute field must not have more than :max items.',
|
||||
'file' => 'The :attribute field must not be greater than :max kilobytes.',
|
||||
'numeric' => 'The :attribute field must not be greater than :max.',
|
||||
'string' => 'The :attribute field must not be greater than :max characters.',
|
||||
],
|
||||
'max_digits' => 'The :attribute field must not have more than :max digits.',
|
||||
'mimes' => 'The :attribute field must be a file of type: :values.',
|
||||
'mimetypes' => 'The :attribute field must be a file of type: :values.',
|
||||
'min' => [
|
||||
'array' => 'The :attribute field must have at least :min items.',
|
||||
'file' => 'The :attribute field must be at least :min kilobytes.',
|
||||
'numeric' => 'The :attribute field must be at least :min.',
|
||||
'string' => 'The :attribute field must be at least :min characters.',
|
||||
],
|
||||
'min_digits' => 'The :attribute field must have at least :min digits.',
|
||||
'missing' => 'The :attribute field must be missing.',
|
||||
'missing_if' => 'The :attribute field must be missing when :other is :value.',
|
||||
'missing_unless' => 'The :attribute field must be missing unless :other is :value.',
|
||||
'missing_with' => 'The :attribute field must be missing when :values is present.',
|
||||
'missing_with_all' => 'The :attribute field must be missing when :values are present.',
|
||||
'multiple_of' => 'The :attribute field must be a multiple of :value.',
|
||||
'not_in' => 'The selected :attribute is invalid.',
|
||||
'not_regex' => 'The :attribute field format is invalid.',
|
||||
'numeric' => 'The :attribute field must be a number.',
|
||||
'password' => [
|
||||
'letters' => 'The :attribute field must contain at least one letter.',
|
||||
'mixed' => 'The :attribute field must contain at least one uppercase and one lowercase letter.',
|
||||
'numbers' => 'The :attribute field must contain at least one number.',
|
||||
'symbols' => 'The :attribute field must contain at least one symbol.',
|
||||
'uncompromised' => 'The given :attribute has appeared in a data leak. Please choose a different :attribute.',
|
||||
],
|
||||
'present' => 'The :attribute field must be present.',
|
||||
'present_if' => 'The :attribute field must be present when :other is :value.',
|
||||
'present_unless' => 'The :attribute field must be present unless :other is :value.',
|
||||
'present_with' => 'The :attribute field must be present when :values is present.',
|
||||
'present_with_all' => 'The :attribute field must be present when :values are present.',
|
||||
'prohibited' => 'The :attribute field is prohibited.',
|
||||
'prohibited_if' => 'The :attribute field is prohibited when :other is :value.',
|
||||
'prohibited_if_accepted' => 'The :attribute field is prohibited when :other is accepted.',
|
||||
'prohibited_if_declined' => 'The :attribute field is prohibited when :other is declined.',
|
||||
'prohibited_unless' => 'The :attribute field is prohibited unless :other is in :values.',
|
||||
'prohibits' => 'The :attribute field prohibits :other from being present.',
|
||||
'regex' => 'The :attribute field format is invalid.',
|
||||
'required' => 'The :attribute field is required.',
|
||||
'required_array_keys' => 'The :attribute field must contain entries for: :values.',
|
||||
'required_if' => 'The :attribute field is required when :other is :value.',
|
||||
'required_if_accepted' => 'The :attribute field is required when :other is accepted.',
|
||||
'required_if_declined' => 'The :attribute field is required when :other is declined.',
|
||||
'required_unless' => 'The :attribute field is required unless :other is in :values.',
|
||||
'required_with' => 'The :attribute field is required when :values is present.',
|
||||
'required_with_all' => 'The :attribute field is required when :values are present.',
|
||||
'required_without' => 'The :attribute field is required when :values is not present.',
|
||||
'required_without_all' => 'The :attribute field is required when none of :values are present.',
|
||||
'same' => 'The :attribute field must match :other.',
|
||||
'size' => [
|
||||
'array' => 'The :attribute field must contain :size items.',
|
||||
'file' => 'The :attribute field must be :size kilobytes.',
|
||||
'numeric' => 'The :attribute field must be :size.',
|
||||
'string' => 'The :attribute field must be :size characters.',
|
||||
],
|
||||
'starts_with' => 'The :attribute field must start with one of the following: :values.',
|
||||
'string' => 'The :attribute field must be a string.',
|
||||
'timezone' => 'The :attribute field must be a valid timezone.',
|
||||
'unique' => 'The :attribute has already been taken.',
|
||||
'uploaded' => 'The :attribute failed to upload.',
|
||||
'uppercase' => 'The :attribute field must be uppercase.',
|
||||
'url' => 'The :attribute field must be a valid URL.',
|
||||
'ulid' => 'The :attribute field must be a valid ULID.',
|
||||
'uuid' => 'The :attribute field must be a valid 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' => [],
|
||||
|
||||
];
|
||||
20
lang/pt-BR/auth.php
Normal file
20
lang/pt-BR/auth.php
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<?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' => 'Essas credenciais não correspondem aos nossos registros.',
|
||||
'password' => 'A senha fornecida está incorreta.',
|
||||
'throttle' => 'Muitas tentativas de login. Por favor, tente novamente em :seconds segundos.',
|
||||
|
||||
];
|
||||
19
lang/pt-BR/pagination.php
Normal file
19
lang/pt-BR/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' => '« Anterior',
|
||||
'next' => 'Próximo »',
|
||||
|
||||
];
|
||||
22
lang/pt-BR/passwords.php
Normal file
22
lang/pt-BR/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' => 'Sua senha foi redefinida.',
|
||||
'sent' => 'Enviamos o link de redefinição de senha por e-mail.',
|
||||
'throttled' => 'Por favor, aguarde antes de tentar novamente.',
|
||||
'token' => 'Este token de redefinição de senha é inválido.',
|
||||
'user' => "Não conseguimos encontrar um usuário com esse endereço de e-mail.",
|
||||
|
||||
];
|
||||
200
lang/pt-BR/validation.php
Normal file
200
lang/pt-BR/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' => 'O campo :attribute deve ser aceito.',
|
||||
'accepted_if' => 'O campo :attribute deve ser aceito quando :other for :value.',
|
||||
'active_url' => 'O campo :attribute deve ser uma URL válida.',
|
||||
'after' => 'O campo :attribute deve ser uma data posterior a :date.',
|
||||
'after_or_equal' => 'O campo :attribute deve ser uma data posterior ou igual a :date.',
|
||||
'alpha' => 'O campo :attribute deve conter apenas letras.',
|
||||
'alpha_dash' => 'O campo :attribute deve conter apenas letras, números, hifens e underscores.',
|
||||
'alpha_num' => 'O campo :attribute deve conter apenas letras e números.',
|
||||
'any_of' => 'O campo :attribute é inválido.',
|
||||
'array' => 'O campo :attribute deve ser um array.',
|
||||
'ascii' => 'O campo :attribute deve conter apenas caracteres alfanuméricos e símbolos de um byte.',
|
||||
'before' => 'O campo :attribute deve ser uma data anterior a :date.',
|
||||
'before_or_equal' => 'O campo :attribute deve ser uma data anterior ou igual a :date.',
|
||||
'between' => [
|
||||
'array' => 'O campo :attribute deve ter entre :min e :max itens.',
|
||||
'file' => 'O campo :attribute deve estar entre :min e :max kilobytes.',
|
||||
'numeric' => 'O campo :attribute deve estar entre :min e :max.',
|
||||
'string' => 'O campo :attribute deve estar entre :min e :max caracteres.',
|
||||
],
|
||||
'boolean' => 'O campo :attribute deve ser verdadeiro ou falso.',
|
||||
'can' => 'O campo :attribute contém um valor não autorizado.',
|
||||
'confirmed' => 'A confirmação do campo :attribute não corresponde.',
|
||||
'contains' => 'O campo :attribute está faltando um valor obrigatório.',
|
||||
'current_password' => 'A senha está incorreta.',
|
||||
'date' => 'O campo :attribute deve ser uma data válida.',
|
||||
'date_equals' => 'O campo :attribute deve ser uma data igual a :date.',
|
||||
'date_format' => 'O campo :attribute deve corresponder ao formato :format.',
|
||||
'decimal' => 'O campo :attribute deve ter :decimal casas decimais.',
|
||||
'declined' => 'O campo :attribute deve ser recusado.',
|
||||
'declined_if' => 'O campo :attribute deve ser recusado quando :other for :value.',
|
||||
'different' => 'O campo :attribute e :other devem ser diferentes.',
|
||||
'digits' => 'O campo :attribute deve ter :digits dígitos.',
|
||||
'digits_between' => 'O campo :attribute deve ter entre :min e :max dígitos.',
|
||||
'dimensions' => 'O campo :attribute deve ter dimensões de imagem válidas.',
|
||||
'distinct' => 'O campo :attribute tem um valor duplicado.',
|
||||
'doesnt_contain' => 'O campo :attribute não deve conter nenhum dos seguintes: :values.',
|
||||
'doesnt_end_with' => 'O campo :attribute não deve terminar com nenhum dos seguintes: :values.',
|
||||
'doesnt_start_with' => 'O campo :attribute não deve começar com nenhum dos seguintes: :values.',
|
||||
'email' => 'O campo :attribute deve ser um endereço de e-mail válido.',
|
||||
'encoding' => 'O campo :attribute deve ser codificado em :encoding.',
|
||||
'ends_with' => 'O campo :attribute deve terminar com um dos seguintes: :values.',
|
||||
'enum' => 'O :attribute selecionado é inválido.',
|
||||
'exists' => 'O :attribute selecionado é inválido.',
|
||||
'extensions' => 'O campo :attribute deve ter uma das seguintes extensões: :values.',
|
||||
'file' => 'O campo :attribute deve ser um arquivo.',
|
||||
'filled' => 'O campo :attribute deve ter um valor.',
|
||||
'gt' => [
|
||||
'array' => 'O campo :attribute deve ter mais de :value itens.',
|
||||
'file' => 'O campo :attribute deve ser maior que :value kilobytes.',
|
||||
'numeric' => 'O campo :attribute deve ser maior que :value.',
|
||||
'string' => 'O campo :attribute deve ser maior que :value caracteres.',
|
||||
],
|
||||
'gte' => [
|
||||
'array' => 'O campo :attribute deve ter :value itens ou mais.',
|
||||
'file' => 'O campo :attribute deve ser maior ou igual a :value kilobytes.',
|
||||
'numeric' => 'O campo :attribute deve ser maior ou igual a :value.',
|
||||
'string' => 'O campo :attribute deve ser maior ou igual a :value caracteres.',
|
||||
],
|
||||
'hex_color' => 'O campo :attribute deve ser uma cor hexadecimal válida.',
|
||||
'image' => 'O campo :attribute deve ser uma imagem.',
|
||||
'in' => 'O :attribute selecionado é inválido.',
|
||||
'in_array' => 'O campo :attribute deve existir em :other.',
|
||||
'in_array_keys' => 'O campo :attribute deve conter pelo menos uma das seguintes chaves: :values.',
|
||||
'integer' => 'O campo :attribute deve ser um inteiro.',
|
||||
'ip' => 'O campo :attribute deve ser um endereço IP válido.',
|
||||
'ipv4' => 'O campo :attribute deve ser um endereço IPv4 válido.',
|
||||
'ipv6' => 'O campo :attribute deve ser um endereço IPv6 válido.',
|
||||
'json' => 'O campo :attribute deve ser uma string JSON válida.',
|
||||
'list' => 'O campo :attribute deve ser uma lista.',
|
||||
'lowercase' => 'O campo :attribute deve estar em minúsculas.',
|
||||
'lt' => [
|
||||
'array' => 'O campo :attribute deve ter menos de :value itens.',
|
||||
'file' => 'O campo :attribute deve ser menor que :value kilobytes.',
|
||||
'numeric' => 'O campo :attribute deve ser menor que :value.',
|
||||
'string' => 'O campo :attribute deve ser menor que :value caracteres.',
|
||||
],
|
||||
'lte' => [
|
||||
'array' => 'O campo :attribute deve ter :value itens ou menos.',
|
||||
'file' => 'O campo :attribute deve ser menor ou igual a :value kilobytes.',
|
||||
'numeric' => 'O campo :attribute deve ser menor ou igual a :value.',
|
||||
'string' => 'O campo :attribute deve ser menor ou igual a :value caracteres.',
|
||||
],
|
||||
'mac_address' => 'O campo :attribute deve ser um endereço MAC válido.',
|
||||
'max' => [
|
||||
'array' => 'O campo :attribute deve ter no máximo :max itens.',
|
||||
'file' => 'O campo :attribute deve ter no máximo :max kilobytes.',
|
||||
'numeric' => 'O campo :attribute deve ter no máximo :max.',
|
||||
'string' => 'O campo :attribute deve ter no máximo :max caracteres.',
|
||||
],
|
||||
'max_digits' => 'O campo :attribute não deve ter mais que :max dígitos.',
|
||||
'mimes' => 'O campo :attribute deve ser um arquivo do tipo: :values.',
|
||||
'mimetypes' => 'O campo :attribute deve ser um arquivo do tipo: :values.',
|
||||
'min' => [
|
||||
'array' => 'O campo :attribute deve ter pelo menos :min itens.',
|
||||
'file' => 'O campo :attribute deve ter pelo menos :min kilobytes.',
|
||||
'numeric' => 'O campo :attribute deve ter pelo menos :min.',
|
||||
'string' => 'O campo :attribute deve ter pelo menos :min caracteres.',
|
||||
],
|
||||
'min_digits' => 'O campo :attribute deve ter pelo menos :min dígitos.',
|
||||
'missing' => 'O campo :attribute deve estar ausente.',
|
||||
'missing_if' => 'O campo :attribute deve estar ausente quando :other for :value.',
|
||||
'missing_unless' => 'O campo :attribute deve estar ausente a menos que :other seja :value.',
|
||||
'missing_with' => 'O campo :attribute deve estar ausente quando :values estiver presente.',
|
||||
'missing_with_all' => 'O campo :attribute deve estar ausente quando :values estiverem presentes.',
|
||||
'multiple_of' => 'O campo :attribute deve ser um múltiplo de :value.',
|
||||
'not_in' => 'O :attribute selecionado é inválido.',
|
||||
'not_regex' => 'O formato do campo :attribute é inválido.',
|
||||
'numeric' => 'O campo :attribute deve ser um número.',
|
||||
'password' => [
|
||||
'letters' => 'O campo :attribute deve conter pelo menos uma letra.',
|
||||
'mixed' => 'O campo :attribute deve conter pelo menos uma letra maiúscula e uma minúscula.',
|
||||
'numbers' => 'O campo :attribute deve conter pelo menos um número.',
|
||||
'symbols' => 'O campo :attribute deve conter pelo menos um símbolo.',
|
||||
'uncompromised' => 'O :attribute fornecido apareceu em um vazamento de dados. Por favor, escolha um :attribute diferente.',
|
||||
],
|
||||
'present' => 'O campo :attribute deve estar presente.',
|
||||
'present_if' => 'O campo :attribute deve estar presente quando :other for :value.',
|
||||
'present_unless' => 'O campo :attribute deve estar presente a menos que :other seja :value.',
|
||||
'present_with' => 'O campo :attribute deve estar presente quando :values estiver presente.',
|
||||
'present_with_all' => 'O campo :attribute deve estar presente quando :values estiverem presentes.',
|
||||
'prohibited' => 'O campo :attribute é proibido.',
|
||||
'prohibited_if' => 'O campo :attribute é proibido quando :other for :value.',
|
||||
'prohibited_if_accepted' => 'O campo :attribute é proibido quando :other for aceito.',
|
||||
'prohibited_if_declined' => 'O campo :attribute é proibido quando :other for recusado.',
|
||||
'prohibited_unless' => 'O campo :attribute é proibido a menos que :other esteja em :values.',
|
||||
'prohibits' => 'O campo :attribute proíbe :other de estar presente.',
|
||||
'regex' => 'O formato do campo :attribute é inválido.',
|
||||
'required' => 'O campo :attribute é obrigatório.',
|
||||
'required_array_keys' => 'O campo :attribute deve conter entradas para: :values.',
|
||||
'required_if' => 'O campo :attribute é obrigatório quando :other for :value.',
|
||||
'required_if_accepted' => 'O campo :attribute é obrigatório quando :other for aceito.',
|
||||
'required_if_declined' => 'O campo :attribute é obrigatório quando :other for recusado.',
|
||||
'required_unless' => 'O campo :attribute é obrigatório a menos que :other esteja em :values.',
|
||||
'required_with' => 'O campo :attribute é obrigatório quando :values estiver presente.',
|
||||
'required_with_all' => 'O campo :attribute é obrigatório quando :values estiverem presentes.',
|
||||
'required_without' => 'O campo :attribute é obrigatório quando :values não estiver presente.',
|
||||
'required_without_all' => 'O campo :attribute é obrigatório quando nenhum dos :values estiver presente.',
|
||||
'same' => 'O campo :attribute deve ser igual a :other.',
|
||||
'size' => [
|
||||
'array' => 'O campo :attribute deve conter :size itens.',
|
||||
'file' => 'O campo :attribute deve ter :size kilobytes.',
|
||||
'numeric' => 'O campo :attribute deve ser :size.',
|
||||
'string' => 'O campo :attribute deve ter :size caracteres.',
|
||||
],
|
||||
'starts_with' => 'O campo :attribute deve começar com um dos seguintes: :values.',
|
||||
'string' => 'O campo :attribute deve ser uma string.',
|
||||
'timezone' => 'O campo :attribute deve ser um fuso horário válido.',
|
||||
'unique' => 'O :attribute já foi utilizado.',
|
||||
'uploaded' => 'O :attribute falhou ao ser enviado.',
|
||||
'uppercase' => 'O campo :attribute deve estar em maiúsculo.',
|
||||
'url' => 'O campo :attribute deve ser uma URL válida.',
|
||||
'ulid' => 'O campo :attribute deve ser um ULID válido.',
|
||||
'uuid' => 'O campo :attribute deve ser um UUID válido.',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| 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' => [],
|
||||
|
||||
];
|
||||
|
|
@ -19,111 +19,111 @@ @layer utilities {
|
|||
}
|
||||
|
||||
:root {
|
||||
--background: #fcfcfc;
|
||||
--background: #f7f9f3;
|
||||
--foreground: #000000;
|
||||
--card: #ffffff;
|
||||
--card-foreground: #000000;
|
||||
--popover: #fcfcfc;
|
||||
--popover: #ffffff;
|
||||
--popover-foreground: #000000;
|
||||
--primary: #000000;
|
||||
--primary: #4f46e5;
|
||||
--primary-foreground: #ffffff;
|
||||
--secondary: #ebebeb;
|
||||
--secondary-foreground: #000000;
|
||||
--muted: #f5f5f5;
|
||||
--muted-foreground: #525252;
|
||||
--accent: #ebebeb;
|
||||
--secondary: #14b8a6;
|
||||
--secondary-foreground: #ffffff;
|
||||
--muted: #f0f0f0;
|
||||
--muted-foreground: #333333;
|
||||
--accent: #f59e0b;
|
||||
--accent-foreground: #000000;
|
||||
--destructive: #e54b4f;
|
||||
--destructive: #ef4444;
|
||||
--destructive-foreground: #ffffff;
|
||||
--border: #e4e4e4;
|
||||
--input: #ebebeb;
|
||||
--ring: #000000;
|
||||
--chart-1: #ffae04;
|
||||
--chart-2: #2d62ef;
|
||||
--chart-3: #a4a4a4;
|
||||
--chart-4: #e4e4e4;
|
||||
--chart-5: #747474;
|
||||
--sidebar: #fcfcfc;
|
||||
--border: #000000;
|
||||
--input: #737373;
|
||||
--ring: #a5b4fc;
|
||||
--chart-1: #4f46e5;
|
||||
--chart-2: #14b8a6;
|
||||
--chart-3: #f59e0b;
|
||||
--chart-4: #ec4899;
|
||||
--chart-5: #22c55e;
|
||||
--sidebar: #f7f9f3;
|
||||
--sidebar-foreground: #000000;
|
||||
--sidebar-primary: #000000;
|
||||
--sidebar-primary: #4f46e5;
|
||||
--sidebar-primary-foreground: #ffffff;
|
||||
--sidebar-accent: #ebebeb;
|
||||
--sidebar-accent: #f59e0b;
|
||||
--sidebar-accent-foreground: #000000;
|
||||
--sidebar-border: #ebebeb;
|
||||
--sidebar-ring: #000000;
|
||||
--font-sans: Geist, sans-serif;
|
||||
--font-serif: Georgia, serif;
|
||||
--font-mono: Geist Mono, monospace;
|
||||
--radius: 0.5rem;
|
||||
--sidebar-border: #000000;
|
||||
--sidebar-ring: #a5b4fc;
|
||||
--font-sans: DM Sans, sans-serif;
|
||||
--font-serif: DM Sans, sans-serif;
|
||||
--font-mono: Space Mono, monospace;
|
||||
--radius: 1rem;
|
||||
--shadow-x: 0px;
|
||||
--shadow-y: 1px;
|
||||
--shadow-blur: 2px;
|
||||
--shadow-y: 0px;
|
||||
--shadow-blur: 0px;
|
||||
--shadow-spread: 0px;
|
||||
--shadow-opacity: 0.18;
|
||||
--shadow-color: hsl(0 0% 0%);
|
||||
--shadow-2xs: 0px 1px 2px 0px hsl(0 0% 0% / 0.09);
|
||||
--shadow-xs: 0px 1px 2px 0px hsl(0 0% 0% / 0.09);
|
||||
--shadow-sm: 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 1px 2px -1px hsl(0 0% 0% / 0.18);
|
||||
--shadow: 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 1px 2px -1px hsl(0 0% 0% / 0.18);
|
||||
--shadow-md: 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 2px 4px -1px hsl(0 0% 0% / 0.18);
|
||||
--shadow-lg: 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 4px 6px -1px hsl(0 0% 0% / 0.18);
|
||||
--shadow-xl: 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 8px 10px -1px hsl(0 0% 0% / 0.18);
|
||||
--shadow-2xl: 0px 1px 2px 0px hsl(0 0% 0% / 0.45);
|
||||
--tracking-normal: 0em;
|
||||
--shadow-opacity: 0.05;
|
||||
--shadow-color: #1a1a1a;
|
||||
--shadow-2xs: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.03);
|
||||
--shadow-xs: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.03);
|
||||
--shadow-sm: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.05), 0px 1px 2px -1px hsl(0 0% 10.1961% / 0.05);
|
||||
--shadow: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.05), 0px 1px 2px -1px hsl(0 0% 10.1961% / 0.05);
|
||||
--shadow-md: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.05), 0px 2px 4px -1px hsl(0 0% 10.1961% / 0.05);
|
||||
--shadow-lg: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.05), 0px 4px 6px -1px hsl(0 0% 10.1961% / 0.05);
|
||||
--shadow-xl: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.05), 0px 8px 10px -1px hsl(0 0% 10.1961% / 0.05);
|
||||
--shadow-2xl: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.13);
|
||||
--tracking-normal: normal;
|
||||
--spacing: 0.25rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: #000000;
|
||||
--foreground: #ffffff;
|
||||
--card: #090909;
|
||||
--card: #1a212b;
|
||||
--card-foreground: #ffffff;
|
||||
--popover: #121212;
|
||||
--popover: #1a212b;
|
||||
--popover-foreground: #ffffff;
|
||||
--primary: #ffffff;
|
||||
--primary: #818cf8;
|
||||
--primary-foreground: #000000;
|
||||
--secondary: #222222;
|
||||
--secondary-foreground: #ffffff;
|
||||
--muted: #1d1d1d;
|
||||
--muted-foreground: #a4a4a4;
|
||||
--accent: #333333;
|
||||
--accent-foreground: #ffffff;
|
||||
--destructive: #ff5b5b;
|
||||
--secondary: #2dd4bf;
|
||||
--secondary-foreground: #000000;
|
||||
--muted: #333333;
|
||||
--muted-foreground: #cccccc;
|
||||
--accent: #fcd34d;
|
||||
--accent-foreground: #000000;
|
||||
--destructive: #f87171;
|
||||
--destructive-foreground: #000000;
|
||||
--border: #242424;
|
||||
--input: #333333;
|
||||
--ring: #a4a4a4;
|
||||
--chart-1: #ffae04;
|
||||
--chart-2: #2671f4;
|
||||
--chart-3: #747474;
|
||||
--chart-4: #525252;
|
||||
--chart-5: #e4e4e4;
|
||||
--sidebar: #121212;
|
||||
--border: #545454;
|
||||
--input: #ffffff;
|
||||
--ring: #818cf8;
|
||||
--chart-1: #818cf8;
|
||||
--chart-2: #2dd4bf;
|
||||
--chart-3: #fcd34d;
|
||||
--chart-4: #f472b6;
|
||||
--chart-5: #4ade80;
|
||||
--sidebar: #000000;
|
||||
--sidebar-foreground: #ffffff;
|
||||
--sidebar-primary: #ffffff;
|
||||
--sidebar-primary: #818cf8;
|
||||
--sidebar-primary-foreground: #000000;
|
||||
--sidebar-accent: #333333;
|
||||
--sidebar-accent-foreground: #ffffff;
|
||||
--sidebar-border: #333333;
|
||||
--sidebar-ring: #a4a4a4;
|
||||
--font-sans: Geist, sans-serif;
|
||||
--font-serif: Georgia, serif;
|
||||
--font-mono: Geist Mono, monospace;
|
||||
--radius: 0.5rem;
|
||||
--sidebar-accent: #fcd34d;
|
||||
--sidebar-accent-foreground: #000000;
|
||||
--sidebar-border: #ffffff;
|
||||
--sidebar-ring: #818cf8;
|
||||
--font-sans: DM Sans, sans-serif;
|
||||
--font-serif: DM Sans, sans-serif;
|
||||
--font-mono: Space Mono, monospace;
|
||||
--radius: 1rem;
|
||||
--shadow-x: 0px;
|
||||
--shadow-y: 1px;
|
||||
--shadow-blur: 2px;
|
||||
--shadow-y: 0px;
|
||||
--shadow-blur: 0px;
|
||||
--shadow-spread: 0px;
|
||||
--shadow-opacity: 0.18;
|
||||
--shadow-color: hsl(0 0% 0%);
|
||||
--shadow-2xs: 0px 1px 2px 0px hsl(0 0% 0% / 0.09);
|
||||
--shadow-xs: 0px 1px 2px 0px hsl(0 0% 0% / 0.09);
|
||||
--shadow-sm: 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 1px 2px -1px hsl(0 0% 0% / 0.18);
|
||||
--shadow: 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 1px 2px -1px hsl(0 0% 0% / 0.18);
|
||||
--shadow-md: 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 2px 4px -1px hsl(0 0% 0% / 0.18);
|
||||
--shadow-lg: 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 4px 6px -1px hsl(0 0% 0% / 0.18);
|
||||
--shadow-xl: 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 8px 10px -1px hsl(0 0% 0% / 0.18);
|
||||
--shadow-2xl: 0px 1px 2px 0px hsl(0 0% 0% / 0.45);
|
||||
--shadow-opacity: 0.05;
|
||||
--shadow-color: #1a1a1a;
|
||||
--shadow-2xs: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.03);
|
||||
--shadow-xs: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.03);
|
||||
--shadow-sm: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.05), 0px 1px 2px -1px hsl(0 0% 10.1961% / 0.05);
|
||||
--shadow: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.05), 0px 1px 2px -1px hsl(0 0% 10.1961% / 0.05);
|
||||
--shadow-md: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.05), 0px 2px 4px -1px hsl(0 0% 10.1961% / 0.05);
|
||||
--shadow-lg: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.05), 0px 4px 6px -1px hsl(0 0% 10.1961% / 0.05);
|
||||
--shadow-xl: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.05), 0px 8px 10px -1px hsl(0 0% 10.1961% / 0.05);
|
||||
--shadow-2xl: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.13);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
|
|
@ -177,6 +177,17 @@ @theme inline {
|
|||
--shadow-lg: var(--shadow-lg);
|
||||
--shadow-xl: var(--shadow-xl);
|
||||
--shadow-2xl: var(--shadow-2xl);
|
||||
|
||||
--tracking-tighter: calc(var(--tracking-normal) - 0.05em);
|
||||
--tracking-tight: calc(var(--tracking-normal) - 0.025em);
|
||||
--tracking-normal: var(--tracking-normal);
|
||||
--tracking-wide: calc(var(--tracking-normal) + 0.025em);
|
||||
--tracking-wider: calc(var(--tracking-normal) + 0.05em);
|
||||
--tracking-widest: calc(var(--tracking-normal) + 0.1em);
|
||||
}
|
||||
|
||||
body {
|
||||
letter-spacing: var(--tracking-normal);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
|
|
|
|||
|
|
@ -15,12 +15,11 @@ withDefaults(
|
|||
|
||||
<template>
|
||||
<header
|
||||
v-if="breadcrumbs && breadcrumbs.length > 0"
|
||||
class="flex h-16 shrink-0 items-center gap-2 border-b border-border px-6 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12 md:px-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<SidebarTrigger class="-ml-1" />
|
||||
<template v-if="breadcrumbs && breadcrumbs.length > 0">
|
||||
<Breadcrumbs :breadcrumbs="breadcrumbs" />
|
||||
</template>
|
||||
<Breadcrumbs :breadcrumbs="breadcrumbs" />
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
|
@ -1,158 +1,226 @@
|
|||
<script setup lang="ts">
|
||||
import { parseDate } from '@internationalized/date';
|
||||
import { IconCalendar } from '@tabler/icons-vue';
|
||||
import { ref, watch } from 'vue';
|
||||
import { ref, watch, computed } from 'vue';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Calendar } from '@/components/ui/calendar';
|
||||
import { InputMask } from '@/components/ui/input';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import { useDateMaska } from '@/composables/useDateMaska';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import dayjs from '@/dayjs';
|
||||
|
||||
const props = defineProps({
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
align: {
|
||||
type: String as () => 'start' | 'center' | 'end',
|
||||
default: 'end',
|
||||
validator: (value: string) => ['start', 'end'].includes(value),
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
align: {
|
||||
type: String as () => 'start' | 'center' | 'end',
|
||||
default: 'end',
|
||||
validator: (value: string) => ['start', 'center', 'end'].includes(value),
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showTime: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: 'Select date',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
|
||||
const { dateOptions } = useDateMaska();
|
||||
|
||||
// Parse input value into date
|
||||
function parseInput(value: string) {
|
||||
if (!value) return undefined;
|
||||
if (!value) return undefined;
|
||||
|
||||
// Parse date string (YYYY-MM-DD format)
|
||||
try {
|
||||
return parseDate(value);
|
||||
} catch {
|
||||
try {
|
||||
const date = dayjs(value);
|
||||
if (date.isValid()) {
|
||||
return parseDate(date.format('YYYY-MM-DD'));
|
||||
}
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const internalDate = ref(parseInput(props.modelValue));
|
||||
const displayValue = ref(
|
||||
props.modelValue && dayjs(props.modelValue).isValid()
|
||||
? dayjs(props.modelValue).format('MM/DD/YYYY')
|
||||
: '',
|
||||
);
|
||||
const popoverOpen = ref(false);
|
||||
|
||||
// Parse American date format (MM/DD/YYYY) to YYYY-MM-DD
|
||||
function parseAmericanDate(value: string): string | null {
|
||||
if (!value) return null;
|
||||
// Time state (24-hour format)
|
||||
const selectedHour = ref(
|
||||
props.modelValue && dayjs(props.modelValue).isValid()
|
||||
? dayjs(props.modelValue).format('HH')
|
||||
: '09',
|
||||
);
|
||||
const selectedMinute = ref(
|
||||
props.modelValue && dayjs(props.modelValue).isValid()
|
||||
? dayjs(props.modelValue).format('mm')
|
||||
: '00',
|
||||
);
|
||||
|
||||
// Remove any non-digit characters
|
||||
const digits = value.replace(/\D/g, '');
|
||||
// Generate hours (00-23)
|
||||
const hours = computed(() => {
|
||||
return Array.from({ length: 24 }, (_, i) => {
|
||||
return i.toString().padStart(2, '0');
|
||||
});
|
||||
});
|
||||
|
||||
// Try to parse MM/DD/YYYY format
|
||||
if (digits.length === 8) {
|
||||
const month = digits.substring(0, 2);
|
||||
const day = digits.substring(2, 4);
|
||||
const year = digits.substring(4, 8);
|
||||
// Generate minutes (00-59 in 5-minute intervals)
|
||||
const minutes = computed(() => {
|
||||
return Array.from({ length: 12 }, (_, i) => {
|
||||
return (i * 5).toString().padStart(2, '0');
|
||||
});
|
||||
});
|
||||
|
||||
const dateStr = `${year}-${month}-${day}`;
|
||||
const parsed = dayjs(dateStr, 'YYYY-MM-DD');
|
||||
// Build full datetime string
|
||||
function buildDateTime(dateStr: string | null): string | null {
|
||||
if (!dateStr) return null;
|
||||
|
||||
if (parsed.isValid()) {
|
||||
return dateStr;
|
||||
if (!props.showTime) {
|
||||
return dateStr;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
const timeStr = `${selectedHour.value}:${selectedMinute.value}:00`;
|
||||
return `${dateStr}T${timeStr}`;
|
||||
}
|
||||
|
||||
// Handle manual input change
|
||||
function onInputChange(event: Event) {
|
||||
const value = (event.target as HTMLInputElement).value;
|
||||
displayValue.value = value;
|
||||
|
||||
const parsedDate = parseAmericanDate(value);
|
||||
if (parsedDate) {
|
||||
internalDate.value = parseInput(parsedDate);
|
||||
emit('update:modelValue', parsedDate);
|
||||
}
|
||||
// Handle time change
|
||||
function onTimeChange() {
|
||||
if (internalDate.value) {
|
||||
const dateStr = internalDate.value.toString();
|
||||
emit('update:modelValue', buildDateTime(dateStr));
|
||||
}
|
||||
}
|
||||
|
||||
// Parse input value into date component
|
||||
const isInternalUpdate = ref(false);
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newVal) => {
|
||||
if (isInternalUpdate.value) {
|
||||
isInternalUpdate.value = false;
|
||||
return;
|
||||
}
|
||||
() => props.modelValue,
|
||||
(newVal) => {
|
||||
if (isInternalUpdate.value) {
|
||||
isInternalUpdate.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
internalDate.value = parseInput(newVal);
|
||||
internalDate.value = parseInput(newVal);
|
||||
|
||||
// Update display value
|
||||
if (newVal) {
|
||||
const parsed = dayjs(newVal);
|
||||
if (parsed.isValid()) {
|
||||
displayValue.value = parsed.format('MM/DD/YYYY');
|
||||
}
|
||||
} else {
|
||||
displayValue.value = '';
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
if (newVal) {
|
||||
const parsed = dayjs(newVal);
|
||||
if (parsed.isValid()) {
|
||||
if (props.showTime) {
|
||||
selectedHour.value = parsed.format('HH');
|
||||
selectedMinute.value = parsed.format('mm');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
selectedHour.value = '09';
|
||||
selectedMinute.value = '00';
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
// Watch internal date changes from calendar
|
||||
watch(internalDate, (newDate) => {
|
||||
if (!newDate || isInternalUpdate.value) return;
|
||||
if (!newDate || isInternalUpdate.value) return;
|
||||
|
||||
isInternalUpdate.value = true;
|
||||
const formatted = newDate.toString();
|
||||
emit('update:modelValue', formatted);
|
||||
popoverOpen.value = false;
|
||||
isInternalUpdate.value = true;
|
||||
const formatted = newDate.toString();
|
||||
emit('update:modelValue', buildDateTime(formatted));
|
||||
|
||||
// Update display value when date is selected from calendar
|
||||
if (formatted) {
|
||||
const parsed = dayjs(formatted);
|
||||
if (parsed.isValid()) {
|
||||
displayValue.value = parsed.format('MM/DD/YYYY');
|
||||
if (!props.showTime) {
|
||||
popoverOpen.value = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Display string for the button
|
||||
const displayText = computed(() => {
|
||||
if (!props.modelValue || !dayjs(props.modelValue).isValid()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = dayjs(props.modelValue);
|
||||
|
||||
if (props.showTime) {
|
||||
return parsed.format('MMM D, YYYY [at] HH:mm');
|
||||
}
|
||||
|
||||
return parsed.format('MMM D, YYYY');
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative">
|
||||
<InputMask :id="name" v-model="displayValue" :mask-options="dateOptions" placeholder="MM/DD/YYYY"
|
||||
@input="onInputChange" class="pr-10" :disabled="disabled" />
|
||||
<Popover v-model:open="popoverOpen">
|
||||
<PopoverTrigger :disabled="disabled">
|
||||
<Button type="button" variant="ghost" size="sm" class="absolute top-0 right-0 z-10 h-full" :disabled="disabled">
|
||||
<IconCalendar class="h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-auto p-0" :align="align">
|
||||
<Calendar v-model="internalDate" :placeholder="internalDate" layout="month-and-year" locale="en-US"
|
||||
calendar-label="Date picker" initial-focus />
|
||||
</PopoverContent>
|
||||
<PopoverTrigger as-child :disabled="disabled">
|
||||
<Button
|
||||
:id="name"
|
||||
type="button"
|
||||
variant="outline"
|
||||
class="justify-between text-left font-normal"
|
||||
:class="{ 'text-muted-foreground': !displayText }"
|
||||
:disabled="disabled"
|
||||
>
|
||||
<span>{{ displayText || placeholder }}</span>
|
||||
<IconCalendar class="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-auto p-0" :align="align">
|
||||
<Calendar
|
||||
v-model="internalDate"
|
||||
:placeholder="internalDate"
|
||||
layout="month-and-year"
|
||||
locale="en-US"
|
||||
calendar-label="Date picker"
|
||||
initial-focus
|
||||
/>
|
||||
<!-- Time Picker -->
|
||||
<div v-if="showTime" class="border-t p-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<Select v-model="selectedHour" @update:model-value="onTimeChange">
|
||||
<SelectTrigger class="w-[70px]">
|
||||
<SelectValue placeholder="HH" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="h in hours" :key="h" :value="h">
|
||||
{{ h }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span class="text-muted-foreground">:</span>
|
||||
<Select v-model="selectedMinute" @update:model-value="onTimeChange">
|
||||
<SelectTrigger class="w-[70px]">
|
||||
<SelectValue placeholder="MM" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="m in minutes" :key="m" :value="m">
|
||||
{{ m }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
|
|
|||
318
resources/js/components/MediaDropzone.vue
Normal file
318
resources/js/components/MediaDropzone.vue
Normal file
|
|
@ -0,0 +1,318 @@
|
|||
<script setup lang="ts">
|
||||
import { IconCloudUpload, IconPhoto, IconVideo, IconX, IconLoader2, IconPlus } from '@tabler/icons-vue';
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
interface MediaItem {
|
||||
id: string;
|
||||
url: string;
|
||||
type: string;
|
||||
original_filename: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
media: MediaItem[];
|
||||
maxFiles: number;
|
||||
minFiles?: number;
|
||||
acceptImages: boolean;
|
||||
acceptVideos: boolean;
|
||||
isUploading?: boolean;
|
||||
disabled?: boolean;
|
||||
aspectRatio?: string;
|
||||
variant?: 'default' | 'compact' | 'vertical';
|
||||
showGrid?: boolean;
|
||||
emptyStateText?: string;
|
||||
emptyStateSubtext?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
minFiles: 0,
|
||||
isUploading: false,
|
||||
disabled: false,
|
||||
aspectRatio: 'aspect-square',
|
||||
variant: 'default',
|
||||
showGrid: true,
|
||||
emptyStateText: 'Drag & drop or click to upload',
|
||||
emptyStateSubtext: '',
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
upload: [files: File[]];
|
||||
remove: [mediaId: string];
|
||||
}>();
|
||||
|
||||
const isDragging = ref(false);
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
|
||||
const acceptMimeTypes = computed(() => {
|
||||
const types: string[] = [];
|
||||
if (props.acceptImages) {
|
||||
types.push('image/*');
|
||||
}
|
||||
if (props.acceptVideos) {
|
||||
types.push('video/*');
|
||||
}
|
||||
return types.join(',');
|
||||
});
|
||||
|
||||
const acceptDescription = computed(() => {
|
||||
if (props.acceptImages && props.acceptVideos) {
|
||||
return props.emptyStateSubtext || 'Images or videos';
|
||||
}
|
||||
if (props.acceptImages) {
|
||||
return props.emptyStateSubtext || 'Images only';
|
||||
}
|
||||
if (props.acceptVideos) {
|
||||
return props.emptyStateSubtext || 'Videos only';
|
||||
}
|
||||
return 'No media allowed';
|
||||
});
|
||||
|
||||
const canAddMore = computed(() => props.media.length < props.maxFiles);
|
||||
const isMultiple = computed(() => props.maxFiles > 1);
|
||||
|
||||
const isValidFileType = (file: File): boolean => {
|
||||
const isImage = file.type.startsWith('image/');
|
||||
const isVideo = file.type.startsWith('video/');
|
||||
|
||||
if (isImage && !props.acceptImages) {
|
||||
return false;
|
||||
}
|
||||
if (isVideo && !props.acceptVideos) {
|
||||
return false;
|
||||
}
|
||||
return isImage || isVideo;
|
||||
};
|
||||
|
||||
const handleDragOver = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
if (!props.disabled && canAddMore.value) {
|
||||
isDragging.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
isDragging.value = false;
|
||||
};
|
||||
|
||||
const handleDrop = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
isDragging.value = false;
|
||||
|
||||
if (props.disabled || !canAddMore.value || !e.dataTransfer?.files) {
|
||||
return;
|
||||
}
|
||||
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
processFiles(files);
|
||||
};
|
||||
|
||||
const handleFileSelect = (e: Event) => {
|
||||
const input = e.target as HTMLInputElement;
|
||||
if (!input.files || input.files.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const files = Array.from(input.files);
|
||||
processFiles(files);
|
||||
|
||||
// Reset input
|
||||
input.value = '';
|
||||
};
|
||||
|
||||
const processFiles = (files: File[]) => {
|
||||
// Filter valid files
|
||||
const validFiles = files.filter(isValidFileType);
|
||||
|
||||
if (validFiles.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Limit to remaining slots
|
||||
const remainingSlots = props.maxFiles - props.media.length;
|
||||
const filesToUpload = validFiles.slice(0, remainingSlots);
|
||||
|
||||
emit('upload', filesToUpload);
|
||||
};
|
||||
|
||||
const openFilePicker = () => {
|
||||
if (!props.disabled && canAddMore.value) {
|
||||
fileInput.value?.click();
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = (mediaId: string) => {
|
||||
emit('remove', mediaId);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="relative"
|
||||
@dragover="handleDragOver"
|
||||
@dragleave="handleDragLeave"
|
||||
@drop="handleDrop"
|
||||
>
|
||||
<!-- Empty state: dropzone -->
|
||||
<div
|
||||
v-if="media.length === 0"
|
||||
class="flex flex-col items-center justify-center cursor-pointer transition-all duration-200"
|
||||
:class="[
|
||||
aspectRatio,
|
||||
isDragging
|
||||
? 'bg-primary/5 border-primary border-2 border-dashed'
|
||||
: 'bg-muted/50 hover:bg-muted/70 border-2 border-dashed border-muted-foreground/20 hover:border-muted-foreground/40',
|
||||
variant === 'vertical' ? 'rounded-2xl' : 'rounded-lg',
|
||||
disabled ? 'opacity-50 cursor-not-allowed' : '',
|
||||
]"
|
||||
@click="openFilePicker"
|
||||
>
|
||||
<div v-if="isUploading" class="flex flex-col items-center gap-3">
|
||||
<div class="p-4 rounded-full bg-muted">
|
||||
<IconLoader2 class="h-8 w-8 text-muted-foreground animate-spin" />
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground">Uploading...</p>
|
||||
</div>
|
||||
<div v-else class="flex flex-col items-center gap-3">
|
||||
<div class="p-4 rounded-full bg-muted">
|
||||
<IconCloudUpload v-if="isDragging" class="h-8 w-8 text-primary" />
|
||||
<IconVideo v-else-if="acceptVideos && !acceptImages" class="h-8 w-8 text-muted-foreground" />
|
||||
<IconPhoto v-else class="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<p class="text-sm font-medium text-muted-foreground">{{ emptyStateText }}</p>
|
||||
<p class="text-xs text-muted-foreground/70 mt-1">{{ acceptDescription }}</p>
|
||||
<p v-if="maxFiles > 1" class="text-xs text-muted-foreground/50 mt-1">
|
||||
Up to {{ maxFiles }} {{ maxFiles === 1 ? 'file' : 'files' }}
|
||||
<span v-if="minFiles && minFiles > 1">(min {{ minFiles }})</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Media grid with thumbnails -->
|
||||
<div v-else>
|
||||
<div
|
||||
v-if="showGrid && media.length > 1"
|
||||
class="grid gap-2"
|
||||
:class="media.length === 2 ? 'grid-cols-2' : 'grid-cols-3'"
|
||||
>
|
||||
<div
|
||||
v-for="item in media"
|
||||
:key="item.id"
|
||||
class="relative group aspect-square rounded-lg overflow-hidden bg-muted"
|
||||
>
|
||||
<img
|
||||
v-if="item.type === 'image'"
|
||||
:src="item.url"
|
||||
:alt="item.original_filename"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
<video
|
||||
v-else
|
||||
:src="item.url"
|
||||
class="w-full h-full object-cover bg-black"
|
||||
muted
|
||||
playsinline
|
||||
/>
|
||||
<!-- Video indicator -->
|
||||
<div
|
||||
v-if="item.type === 'video'"
|
||||
class="absolute bottom-1 left-1 bg-black/60 text-white text-[10px] px-1.5 py-0.5 rounded flex items-center gap-0.5"
|
||||
>
|
||||
<IconVideo class="h-3 w-3" />
|
||||
</div>
|
||||
<!-- Remove button -->
|
||||
<button
|
||||
v-if="!disabled"
|
||||
type="button"
|
||||
@click.stop="handleRemove(item.id)"
|
||||
class="absolute top-1 right-1 bg-black/60 hover:bg-black/80 text-white rounded-full p-1 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<IconX class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<!-- Add more button -->
|
||||
<button
|
||||
v-if="canAddMore && !disabled"
|
||||
type="button"
|
||||
@click="openFilePicker"
|
||||
class="aspect-square rounded-lg border-2 border-dashed border-muted-foreground/20 hover:border-muted-foreground/40 flex items-center justify-center transition-colors"
|
||||
:class="isUploading ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer hover:bg-muted/50'"
|
||||
>
|
||||
<IconLoader2 v-if="isUploading" class="h-6 w-6 text-muted-foreground animate-spin" />
|
||||
<IconPlus v-else class="h-6 w-6 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Single media display -->
|
||||
<div
|
||||
v-else
|
||||
class="relative group overflow-hidden bg-muted"
|
||||
:class="[aspectRatio, variant === 'vertical' ? 'rounded-2xl' : 'rounded-lg']"
|
||||
>
|
||||
<img
|
||||
v-if="media[0].type === 'image'"
|
||||
:src="media[0].url"
|
||||
:alt="media[0].original_filename"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
<video
|
||||
v-else
|
||||
:src="media[0].url"
|
||||
class="w-full h-full object-cover bg-black"
|
||||
muted
|
||||
loop
|
||||
playsinline
|
||||
/>
|
||||
<!-- Video indicator -->
|
||||
<div
|
||||
v-if="media[0].type === 'video'"
|
||||
class="absolute bottom-2 left-2 bg-black/60 text-white text-xs px-2 py-1 rounded-full flex items-center gap-1"
|
||||
>
|
||||
<IconVideo class="h-3.5 w-3.5" />
|
||||
<span>Video</span>
|
||||
</div>
|
||||
<!-- Remove button -->
|
||||
<button
|
||||
v-if="!disabled"
|
||||
type="button"
|
||||
@click.stop="handleRemove(media[0].id)"
|
||||
class="absolute top-2 right-2 bg-black/60 hover:bg-black/80 text-white rounded-full p-2 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<IconX class="h-4 w-4" />
|
||||
</button>
|
||||
<!-- Carousel indicator (if more than 1 and not showing grid) -->
|
||||
<div v-if="media.length > 1 && !showGrid" class="absolute bottom-2 left-1/2 -translate-x-1/2 flex gap-1">
|
||||
<div
|
||||
v-for="(_, index) in media.slice(0, 5)"
|
||||
:key="index"
|
||||
class="w-1.5 h-1.5 rounded-full"
|
||||
:class="index === 0 ? 'bg-white' : 'bg-white/50'"
|
||||
/>
|
||||
<span v-if="media.length > 5" class="text-white text-[10px] ml-1">+{{ media.length - 5 }}</span>
|
||||
</div>
|
||||
<!-- Upload more overlay (if can add more and single display) -->
|
||||
<button
|
||||
v-if="canAddMore && !disabled && !showGrid"
|
||||
type="button"
|
||||
@click="openFilePicker"
|
||||
class="absolute bottom-2 right-2 bg-black/60 hover:bg-black/80 text-white rounded-full p-2 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<IconPlus class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hidden file input -->
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
:accept="acceptMimeTypes"
|
||||
:multiple="isMultiple"
|
||||
class="hidden"
|
||||
@change="handleFileSelect"
|
||||
:disabled="disabled || isUploading"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
68
resources/js/components/PhoneMockup.vue
Normal file
68
resources/js/components/PhoneMockup.vue
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
<script setup lang="ts">
|
||||
interface Props {
|
||||
variant?: 'default' | 'minimal';
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
variant: 'default',
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative mx-auto select-none">
|
||||
<!-- Phone Frame - iPhone 14 Pro style -->
|
||||
<div class="relative bg-[#1a1a1a] rounded-[44px] p-[10px] shadow-2xl ring-1 ring-white/10">
|
||||
<!-- Inner frame -->
|
||||
<div class="relative bg-[#0a0a0a] rounded-[34px] overflow-hidden">
|
||||
<!-- Screen Content - iPhone 14 Pro proportions (393x852 scaled down) -->
|
||||
<div class="relative bg-white dark:bg-black w-[270px] h-[585px] overflow-hidden">
|
||||
<!-- iOS Status Bar - dark text on light mode, light text on dark mode -->
|
||||
<div class="absolute top-0 left-0 right-0 h-[44px] flex items-center justify-between px-5 z-40 text-black dark:text-white">
|
||||
<!-- Left: Time -->
|
||||
<span class="text-[15px] font-semibold tracking-tight w-[54px]">9:41</span>
|
||||
|
||||
<!-- Center: Dynamic Island -->
|
||||
<div class="w-[80px] h-[25px] bg-black rounded-full flex items-center justify-center">
|
||||
<div class="w-[6px] h-[6px] rounded-full bg-[#1a1a1a] mr-5"></div>
|
||||
</div>
|
||||
|
||||
<!-- Right: Icons -->
|
||||
<div class="flex items-center gap-[5px] w-[54px] justify-end">
|
||||
<!-- Cellular Signal -->
|
||||
<svg class="w-[17px] h-[12px]" viewBox="0 0 17 12" fill="currentColor">
|
||||
<rect x="0" y="8" width="3" height="4" rx="0.5"/>
|
||||
<rect x="4.5" y="5.5" width="3" height="6.5" rx="0.5"/>
|
||||
<rect x="9" y="3" width="3" height="9" rx="0.5"/>
|
||||
<rect x="13.5" y="0" width="3" height="12" rx="0.5"/>
|
||||
</svg>
|
||||
<!-- WiFi -->
|
||||
<svg class="w-[15px] h-[11px]" viewBox="0 0 15 11" fill="currentColor">
|
||||
<path d="M7.5 8.5a1.25 1.25 0 110 2.5 1.25 1.25 0 010-2.5z"/>
|
||||
<path d="M4.5 7.2c.8-.8 1.9-1.2 3-1.2s2.2.4 3 1.2l-.9.9c-.6-.5-1.3-.8-2.1-.8s-1.5.3-2.1.8l-.9-.9z"/>
|
||||
<path d="M2.1 4.8C3.5 3.4 5.4 2.5 7.5 2.5s4 .9 5.4 2.3l-.9.9C10.8 4.5 9.2 3.8 7.5 3.8S4.2 4.5 3 5.7l-.9-.9z"/>
|
||||
<path d="M0 2.7C1.9 1 4.6 0 7.5 0s5.6 1 7.5 2.7l-.9.9C12.4 2 10.1 1.2 7.5 1.2S2.6 2 .9 3.6L0 2.7z"/>
|
||||
</svg>
|
||||
<!-- Battery -->
|
||||
<svg class="w-[22px] h-[11px]" viewBox="0 0 25 11">
|
||||
<rect x="0.5" y="0.5" width="21" height="10" rx="2.5" stroke="currentColor" fill="none" stroke-opacity="0.4"/>
|
||||
<rect x="2" y="2" width="17" height="7" rx="1.5" fill="currentColor"/>
|
||||
<path d="M23 3.5v4c1 0 1.5-.9 1.5-2s-.5-2-1.5-2z" fill="currentColor" fill-opacity="0.4"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- App Content (below status bar) -->
|
||||
<div class="absolute top-[44px] left-0 right-0 bottom-0 overflow-hidden">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Side buttons -->
|
||||
<div class="absolute -left-[2px] top-[100px] w-[3px] h-[28px] bg-[#2a2a2a] rounded-l-sm"></div>
|
||||
<div class="absolute -left-[2px] top-[145px] w-[3px] h-[50px] bg-[#2a2a2a] rounded-l-sm"></div>
|
||||
<div class="absolute -left-[2px] top-[205px] w-[3px] h-[50px] bg-[#2a2a2a] rounded-l-sm"></div>
|
||||
<div class="absolute -right-[2px] top-[160px] w-[3px] h-[70px] bg-[#2a2a2a] rounded-r-sm"></div>
|
||||
</div>
|
||||
</template>
|
||||
419
resources/js/components/posts/PostForm.vue
Normal file
419
resources/js/components/posts/PostForm.vue
Normal file
|
|
@ -0,0 +1,419 @@
|
|||
<script setup lang="ts">
|
||||
import {
|
||||
IconPhoto,
|
||||
IconVideo,
|
||||
IconX,
|
||||
IconPlus,
|
||||
IconCloudUpload,
|
||||
IconLoader2,
|
||||
IconGripVertical,
|
||||
IconCheck,
|
||||
IconChevronDown,
|
||||
IconSearch,
|
||||
} from '@tabler/icons-vue';
|
||||
import { FocusScope } from 'reka-ui';
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxAnchor,
|
||||
ComboboxEmpty,
|
||||
ComboboxGroup,
|
||||
ComboboxInput,
|
||||
ComboboxItem,
|
||||
ComboboxItemIndicator,
|
||||
ComboboxList,
|
||||
ComboboxTrigger,
|
||||
} from '@/components/ui/combobox';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useMediaRules } from '@/composables/useMediaRules';
|
||||
|
||||
interface MediaItem {
|
||||
id: string;
|
||||
group_id: string | null;
|
||||
url: string;
|
||||
type: string;
|
||||
original_filename: string;
|
||||
}
|
||||
|
||||
interface ContentTypeOption {
|
||||
value: string;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface PinterestBoard {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
platform: string;
|
||||
content: string;
|
||||
media: MediaItem[];
|
||||
contentType: string;
|
||||
contentTypeOptions: ContentTypeOption[];
|
||||
meta?: Record<string, any>;
|
||||
platformData?: {
|
||||
boards?: PinterestBoard[];
|
||||
};
|
||||
charCount: number;
|
||||
maxLength: number;
|
||||
isValid: boolean;
|
||||
validationMessage: string;
|
||||
isUploading?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
isUploading: false,
|
||||
disabled: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:content': [value: string];
|
||||
'update:contentType': [value: string];
|
||||
'update:meta': [value: Record<string, any>];
|
||||
'upload': [files: File[]];
|
||||
'remove-media': [mediaId: string];
|
||||
'reorder-media': [mediaIds: string[]];
|
||||
}>();
|
||||
|
||||
// Media rules
|
||||
const contentTypeRef = computed(() => props.contentType);
|
||||
const { rules: mediaRules } = useMediaRules(contentTypeRef);
|
||||
|
||||
// File drag and drop (for uploading)
|
||||
const isDraggingFile = ref(false);
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
|
||||
// Media reorder drag and drop
|
||||
const draggedItemId = ref<string | null>(null);
|
||||
const dragOverItemId = ref<string | null>(null);
|
||||
|
||||
// Pinterest board selection
|
||||
interface BoardOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const boards = computed(() => props.platformData?.boards || []);
|
||||
const boardOptions = computed<BoardOption[]>(() =>
|
||||
boards.value.map(board => ({
|
||||
value: board.id,
|
||||
label: board.name,
|
||||
}))
|
||||
);
|
||||
|
||||
const selectedBoard = computed({
|
||||
get: () => boardOptions.value.find(b => b.value === props.meta?.board_id),
|
||||
set: (board: BoardOption | undefined) => {
|
||||
emit('update:meta', { ...props.meta, board_id: board?.value || null });
|
||||
},
|
||||
});
|
||||
|
||||
// Computed
|
||||
const hasMultipleContentTypes = computed(() => props.contentTypeOptions.length > 1);
|
||||
const canAddMore = computed(() => props.media.length < mediaRules.value.maxFiles);
|
||||
const acceptString = computed(() => {
|
||||
if (mediaRules.value.acceptImages && mediaRules.value.acceptVideos) return 'image/*,video/*';
|
||||
if (mediaRules.value.acceptImages) return 'image/*';
|
||||
return 'video/*';
|
||||
});
|
||||
|
||||
// File validation
|
||||
const isValidFileType = (file: File): boolean => {
|
||||
const isImage = file.type.startsWith('image/');
|
||||
const isVideo = file.type.startsWith('video/');
|
||||
if (isImage && !mediaRules.value.acceptImages) return false;
|
||||
if (isVideo && !mediaRules.value.acceptVideos) return false;
|
||||
return isImage || isVideo;
|
||||
};
|
||||
|
||||
// File upload handlers
|
||||
const handleFileDragOver = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
// Only handle if dragging files (not internal reorder)
|
||||
if (e.dataTransfer?.types.includes('Files') && !props.disabled && !props.isUploading && canAddMore.value) {
|
||||
isDraggingFile.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileDragLeave = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
isDraggingFile.value = false;
|
||||
};
|
||||
|
||||
const handleFileDrop = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
isDraggingFile.value = false;
|
||||
|
||||
if (props.disabled || props.isUploading || !e.dataTransfer?.files) return;
|
||||
|
||||
const files = Array.from(e.dataTransfer.files).filter(isValidFileType);
|
||||
if (files.length === 0) return;
|
||||
|
||||
const remainingSlots = mediaRules.value.maxFiles - props.media.length;
|
||||
const filesToUpload = files.slice(0, remainingSlots);
|
||||
|
||||
if (filesToUpload.length > 0) {
|
||||
emit('upload', filesToUpload);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileSelect = (e: Event) => {
|
||||
const input = e.target as HTMLInputElement;
|
||||
if (!input.files || input.files.length === 0) return;
|
||||
|
||||
const files = Array.from(input.files).filter(isValidFileType);
|
||||
const remainingSlots = mediaRules.value.maxFiles - props.media.length;
|
||||
const filesToUpload = files.slice(0, remainingSlots);
|
||||
|
||||
if (filesToUpload.length > 0) {
|
||||
emit('upload', filesToUpload);
|
||||
}
|
||||
input.value = '';
|
||||
};
|
||||
|
||||
const openFilePicker = () => {
|
||||
if (!props.disabled && !props.isUploading && canAddMore.value) {
|
||||
fileInput.value?.click();
|
||||
}
|
||||
};
|
||||
|
||||
// Media reorder handlers
|
||||
const handleDragStart = (e: DragEvent, itemId: string) => {
|
||||
if (props.disabled) return;
|
||||
draggedItemId.value = itemId;
|
||||
if (e.dataTransfer) {
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/plain', itemId);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
draggedItemId.value = null;
|
||||
dragOverItemId.value = null;
|
||||
};
|
||||
|
||||
const handleDragOverItem = (e: DragEvent, itemId: string) => {
|
||||
e.preventDefault();
|
||||
if (draggedItemId.value && draggedItemId.value !== itemId) {
|
||||
dragOverItemId.value = itemId;
|
||||
if (e.dataTransfer) {
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragLeaveItem = () => {
|
||||
dragOverItemId.value = null;
|
||||
};
|
||||
|
||||
const handleDropOnItem = (e: DragEvent, targetId: string) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!draggedItemId.value || draggedItemId.value === targetId) {
|
||||
handleDragEnd();
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate new order
|
||||
const currentIds = props.media.map(m => m.id);
|
||||
const draggedIndex = currentIds.indexOf(draggedItemId.value);
|
||||
const targetIndex = currentIds.indexOf(targetId);
|
||||
|
||||
if (draggedIndex === -1 || targetIndex === -1) {
|
||||
handleDragEnd();
|
||||
return;
|
||||
}
|
||||
|
||||
// Create new order by moving dragged item to target position
|
||||
const newIds = [...currentIds];
|
||||
newIds.splice(draggedIndex, 1);
|
||||
newIds.splice(targetIndex, 0, draggedItemId.value);
|
||||
|
||||
emit('reorder-media', newIds);
|
||||
handleDragEnd();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Content Type Selector -->
|
||||
<div v-if="hasMultipleContentTypes">
|
||||
<Label class="text-sm font-medium mb-2 block">Post Type</Label>
|
||||
<Select :model-value="contentType" @update:model-value="emit('update:contentType', $event)">
|
||||
<SelectTrigger class="w-full">
|
||||
<SelectValue placeholder="Select type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="option in contentTypeOptions" :key="option.value" :value="option.value">
|
||||
<div class="flex flex-col">
|
||||
<span>{{ option.label }}</span>
|
||||
<span class="text-xs text-muted-foreground">{{ option.description }}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<!-- Pinterest Board Selector -->
|
||||
<div v-if="platform === 'pinterest' && boards.length > 0">
|
||||
<Label class="text-sm font-medium mb-2 block">Board</Label>
|
||||
<FocusScope as-child>
|
||||
<Combobox v-model="selectedBoard">
|
||||
<ComboboxAnchor as-child>
|
||||
<ComboboxTrigger as-child>
|
||||
<button type="button"
|
||||
class="flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50">
|
||||
{{ selectedBoard ? selectedBoard.label : 'Select a board' }}
|
||||
<IconChevronDown class="h-4 w-4 opacity-50" />
|
||||
</button>
|
||||
</ComboboxTrigger>
|
||||
</ComboboxAnchor>
|
||||
<ComboboxList class="w-[var(--reka-combobox-trigger-width)]">
|
||||
<div class="relative">
|
||||
<ComboboxInput placeholder="Search board..." class="pl-9" />
|
||||
<span class="absolute inset-y-0 start-0 flex items-center justify-center px-3">
|
||||
<IconSearch class="size-4 text-muted-foreground" />
|
||||
</span>
|
||||
</div>
|
||||
<ComboboxEmpty>No board found</ComboboxEmpty>
|
||||
<ComboboxGroup>
|
||||
<ComboboxItem v-for="board in boardOptions" :key="board.value" :value="board">
|
||||
<span class="min-w-0 flex-1 truncate">{{ board.label }}</span>
|
||||
<ComboboxItemIndicator>
|
||||
<IconCheck class="ml-auto h-4 w-4" />
|
||||
</ComboboxItemIndicator>
|
||||
</ComboboxItem>
|
||||
</ComboboxGroup>
|
||||
</ComboboxList>
|
||||
</Combobox>
|
||||
</FocusScope>
|
||||
</div>
|
||||
|
||||
<!-- Media Upload Area -->
|
||||
<div>
|
||||
<Label class="text-sm font-medium mb-2 block">
|
||||
Media
|
||||
<span class="text-muted-foreground font-normal ml-1">
|
||||
({{ props.media.length }}/{{ mediaRules.maxFiles }})
|
||||
</span>
|
||||
<span v-if="mediaRules.minFiles && mediaRules.minFiles > 1" class="text-muted-foreground font-normal">
|
||||
· Min {{ mediaRules.minFiles }}
|
||||
</span>
|
||||
</Label>
|
||||
|
||||
<div class="border-2 border-dashed rounded-lg transition-colors" :class="[
|
||||
isDraggingFile ? 'border-primary bg-primary/5' : 'border-muted-foreground/25 hover:border-muted-foreground/40',
|
||||
props.disabled ? 'opacity-50 cursor-not-allowed' : '',
|
||||
]" @dragover="handleFileDragOver" @dragleave="handleFileDragLeave" @drop="handleFileDrop">
|
||||
<!-- Empty State -->
|
||||
<div v-if="media.length === 0" class="p-8 flex flex-col items-center justify-center cursor-pointer"
|
||||
@click="openFilePicker">
|
||||
<div v-if="isUploading" class="flex flex-col items-center gap-3">
|
||||
<IconLoader2 class="h-10 w-10 text-muted-foreground animate-spin" />
|
||||
<p class="text-sm text-muted-foreground">Uploading...</p>
|
||||
</div>
|
||||
<div v-else class="flex flex-col items-center gap-3">
|
||||
<div class="p-4 rounded-full transition-colors"
|
||||
:class="isDraggingFile ? 'bg-primary/10' : 'bg-muted'">
|
||||
<IconCloudUpload v-if="isDraggingFile" class="h-8 w-8 text-primary" />
|
||||
<IconVideo v-else-if="!mediaRules.acceptImages" class="h-8 w-8 text-muted-foreground" />
|
||||
<IconPhoto v-else class="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<p class="text-sm font-medium">
|
||||
{{ isDraggingFile ? 'Drop to upload' : 'Drag & drop or click to upload' }}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground mt-1">
|
||||
{{ mediaRules.acceptImages && mediaRules.acceptVideos ? 'Photos and videos' :
|
||||
(mediaRules.acceptImages ? 'Photos only' : 'Videos only') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Media Grid -->
|
||||
<div v-else class="p-3">
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<!-- Media Items -->
|
||||
<div v-for="(item, index) in media" :key="item.id" draggable="true"
|
||||
@dragstart="handleDragStart($event, item.id)" @dragend="handleDragEnd"
|
||||
@dragover="handleDragOverItem($event, item.id)" @dragleave="handleDragLeaveItem"
|
||||
@drop="handleDropOnItem($event, item.id)"
|
||||
class="relative aspect-square rounded-lg overflow-hidden bg-muted group transition-all"
|
||||
:class="[
|
||||
draggedItemId === item.id ? 'opacity-50 scale-95' : '',
|
||||
dragOverItemId === item.id ? 'ring-2 ring-primary ring-offset-2' : '',
|
||||
!props.disabled ? 'cursor-grab active:cursor-grabbing' : '',
|
||||
]">
|
||||
<img v-if="item.type === 'image'" :src="item.url" :alt="item.original_filename"
|
||||
class="w-full h-full object-cover pointer-events-none" />
|
||||
<video v-else :src="item.url"
|
||||
class="w-full h-full object-cover bg-black pointer-events-none" muted playsinline />
|
||||
<!-- Order number -->
|
||||
<div v-if="media.length > 1"
|
||||
class="absolute top-1 left-1 bg-black/70 text-white text-[10px] font-bold w-5 h-5 rounded flex items-center justify-center">
|
||||
{{ index + 1 }}
|
||||
</div>
|
||||
<!-- Video indicator -->
|
||||
<div v-if="item.type === 'video'"
|
||||
class="absolute bottom-1 left-1 bg-black/70 text-white text-[10px] px-1.5 py-0.5 rounded flex items-center gap-0.5">
|
||||
<IconVideo class="h-3 w-3" />
|
||||
</div>
|
||||
<!-- Remove button -->
|
||||
<button v-if="!props.disabled" type="button" @click.stop="emit('remove-media', item.id)"
|
||||
class="absolute top-1 right-1 bg-black/70 hover:bg-black/90 text-white rounded-full p-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<IconX class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<!-- Drag hint -->
|
||||
<div v-if="media.length > 1 && !props.disabled"
|
||||
class="absolute bottom-1 right-1 bg-black/50 text-white rounded p-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<IconGripVertical class="h-3.5 w-3.5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add More Button -->
|
||||
<button v-if="canAddMore && !props.disabled" type="button" @click="openFilePicker"
|
||||
class="aspect-square rounded-lg border-2 border-dashed border-muted-foreground/25 hover:border-muted-foreground/40 flex items-center justify-center transition-colors"
|
||||
:class="isUploading ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer hover:bg-muted/50'">
|
||||
<IconLoader2 v-if="isUploading" class="h-6 w-6 text-muted-foreground animate-spin" />
|
||||
<IconPlus v-else class="h-6 w-6 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="media.length > 1" class="text-xs text-muted-foreground mt-2 text-center">
|
||||
Drag to reorder
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hidden file input -->
|
||||
<input ref="fileInput" type="file" :accept="acceptString" :multiple="mediaRules.maxFiles > 1" class="hidden"
|
||||
@change="handleFileSelect" :disabled="props.disabled || props.isUploading" />
|
||||
</div>
|
||||
|
||||
<!-- Caption/Text Input -->
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<Label class="text-sm font-medium">Caption</Label>
|
||||
<span class="text-xs px-2 py-0.5 rounded-full"
|
||||
:class="isValid ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' : 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'">
|
||||
{{ validationMessage }}
|
||||
</span>
|
||||
</div>
|
||||
<Textarea :model-value="content" @update:model-value="emit('update:content', $event)"
|
||||
placeholder="Write your caption..." class="min-h-[120px] resize-none" :disabled="props.disabled" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
33
|
||||
|
|
@ -1,14 +1,4 @@
|
|||
<script setup lang="ts">
|
||||
import {
|
||||
IconDots,
|
||||
IconX,
|
||||
IconPhoto,
|
||||
IconHeart,
|
||||
IconMessageCircle,
|
||||
IconRepeat,
|
||||
IconShare,
|
||||
} from '@tabler/icons-vue';
|
||||
|
||||
interface SocialAccount {
|
||||
id: string;
|
||||
platform: string;
|
||||
|
|
@ -28,170 +18,169 @@ interface Props {
|
|||
socialAccount: SocialAccount;
|
||||
content: string;
|
||||
media: MediaItem[];
|
||||
charCount: number;
|
||||
maxLength: number;
|
||||
isValid: boolean;
|
||||
validationMessage: string;
|
||||
isUploading?: boolean;
|
||||
readonly?: boolean;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:content': [value: string];
|
||||
'upload': [event: Event];
|
||||
'remove-media': [mediaId: string];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bg-white dark:bg-[#161e27] rounded-xl overflow-hidden border border-gray-200 dark:border-[#2e3f50]">
|
||||
<!-- Post Content -->
|
||||
<div class="p-4">
|
||||
<div class="flex gap-3">
|
||||
<!-- Avatar -->
|
||||
<div class="shrink-0">
|
||||
<img
|
||||
v-if="socialAccount.avatar_url"
|
||||
:src="socialAccount.avatar_url"
|
||||
:alt="socialAccount.display_name"
|
||||
class="h-11 w-11 rounded-full object-cover"
|
||||
/>
|
||||
<div v-else class="h-11 w-11 rounded-full bg-[#0085ff] flex items-center justify-center text-white font-bold">
|
||||
<div class="w-full h-full bg-white dark:bg-[#0a1424] text-black dark:text-white overflow-hidden flex flex-col">
|
||||
<!-- Bluesky Mobile Header -->
|
||||
<div
|
||||
class="flex-shrink-0 h-11 flex items-center justify-between px-4 border-b border-gray-200 dark:border-[#1e3a5f]">
|
||||
<!-- Back arrow -->
|
||||
<button class="p-1 -ml-1">
|
||||
<svg class="h-5 w-5 text-black dark:text-white" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2">
|
||||
<path d="M19 12H5M12 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Title -->
|
||||
<span class="text-[16px] font-semibold">Post</span>
|
||||
|
||||
<!-- More options -->
|
||||
<button class="p-1 -mr-1">
|
||||
<svg class="h-5 w-5 text-black dark:text-white" viewBox="0 0 24 24" fill="currentColor">
|
||||
<circle cx="12" cy="12" r="1.5" />
|
||||
<circle cx="12" cy="6" r="1.5" />
|
||||
<circle cx="12" cy="18" r="1.5" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<!-- Post -->
|
||||
<div class="px-3 pt-3 pb-2">
|
||||
<!-- Author row -->
|
||||
<div class="flex items-start gap-3">
|
||||
<!-- Avatar -->
|
||||
<img v-if="socialAccount.avatar_url" :src="socialAccount.avatar_url"
|
||||
:alt="socialAccount.display_name" class="h-11 w-11 rounded-full object-cover shrink-0" />
|
||||
<div v-else
|
||||
class="h-11 w-11 rounded-full bg-gradient-to-br from-[#0085ff] to-[#00d4ff] flex items-center justify-center text-white font-semibold shrink-0">
|
||||
{{ socialAccount.display_name?.charAt(0) }}
|
||||
</div>
|
||||
|
||||
<!-- Name and handle -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-semibold text-[15px] text-black dark:text-white">
|
||||
{{ socialAccount.display_name }}
|
||||
</div>
|
||||
<div class="text-[14px] text-gray-500 dark:text-[#7b8d9e] truncate">
|
||||
@{{ socialAccount.username || 'handle' }}.bsky.social
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content Area -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="font-semibold text-[15px] text-gray-900 dark:text-white">
|
||||
{{ socialAccount.display_name }}
|
||||
</span>
|
||||
<span class="text-gray-500 text-sm">
|
||||
@{{ socialAccount.username }}
|
||||
</span>
|
||||
<span class="text-gray-400 text-sm">· now</span>
|
||||
</div>
|
||||
<button class="p-1 hover:bg-gray-100 dark:hover:bg-[#1e2a35] rounded-full">
|
||||
<IconDots class="h-5 w-5 text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
<!-- Post content -->
|
||||
<div v-if="content" class="mt-3 text-[16px] text-black dark:text-white leading-[22px] whitespace-pre-wrap">
|
||||
{{ content }}
|
||||
</div>
|
||||
|
||||
<!-- Post Content -->
|
||||
<div class="mt-1">
|
||||
<div v-if="props.readonly" class="w-full min-h-[60px] text-[15px] text-gray-900 dark:text-white whitespace-pre-wrap">
|
||||
{{ content || 'No content' }}
|
||||
</div>
|
||||
<textarea
|
||||
v-else
|
||||
:value="content"
|
||||
@input="emit('update:content', ($event.target as HTMLTextAreaElement).value)"
|
||||
class="w-full min-h-[60px] bg-transparent border-0 p-0 text-[15px] text-gray-900 dark:text-white resize-none focus:outline-none focus:ring-0 placeholder:text-gray-400"
|
||||
placeholder="What's up?"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Media -->
|
||||
<div v-if="media.length > 0" class="mt-3">
|
||||
<div
|
||||
class="grid gap-1 rounded-lg overflow-hidden"
|
||||
:class="{
|
||||
'grid-cols-1': media.length === 1,
|
||||
'grid-cols-2': media.length === 2,
|
||||
'grid-cols-2 grid-rows-2': media.length >= 3,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
v-for="(item, index) in media.slice(0, 4)"
|
||||
:key="item.id"
|
||||
class="relative group overflow-hidden"
|
||||
:class="{
|
||||
'aspect-video': media.length === 1,
|
||||
'aspect-square': media.length > 1,
|
||||
'col-span-2': media.length === 3 && index === 0,
|
||||
}"
|
||||
>
|
||||
<img
|
||||
v-if="item.type === 'image'"
|
||||
:src="item.url"
|
||||
:alt="item.original_filename"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
<video
|
||||
v-else
|
||||
:src="item.url"
|
||||
class="w-full h-full object-cover bg-black"
|
||||
muted
|
||||
loop
|
||||
playsinline
|
||||
/>
|
||||
<button
|
||||
v-if="!props.readonly"
|
||||
type="button"
|
||||
@click="emit('remove-media', item.id)"
|
||||
class="absolute top-2 right-2 bg-black/70 text-white rounded-full p-1.5 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<IconX class="h-4 w-4" />
|
||||
</button>
|
||||
<div
|
||||
v-if="media.length > 4 && index === 3"
|
||||
class="absolute inset-0 bg-black/60 flex items-center justify-center"
|
||||
>
|
||||
<span class="text-white text-xl font-semibold">+{{ media.length - 4 }}</span>
|
||||
</div>
|
||||
<!-- Media -->
|
||||
<div v-if="media.length > 0" class="mt-3">
|
||||
<div class="rounded-lg overflow-hidden" :class="{
|
||||
'grid grid-cols-2 gap-0.5': media.length >= 2,
|
||||
}">
|
||||
<div v-for="(item, index) in media.slice(0, 4)" :key="item.id"
|
||||
class="relative overflow-hidden" :class="{
|
||||
'aspect-[4/3]': media.length === 1,
|
||||
'aspect-square': media.length > 1,
|
||||
'col-span-2': media.length === 3 && index === 0,
|
||||
}">
|
||||
<img v-if="item.type === 'image'" :src="item.url" :alt="item.original_filename"
|
||||
class="w-full h-full object-cover" />
|
||||
<video v-else :src="item.url" class="w-full h-full object-cover bg-black" muted loop
|
||||
playsinline />
|
||||
<div v-if="media.length > 4 && index === 3"
|
||||
class="absolute inset-0 bg-black/60 flex items-center justify-center">
|
||||
<span class="text-white text-xl font-semibold">+{{ media.length - 4 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Engagement Actions -->
|
||||
<div class="flex items-center justify-between mt-3 -ml-2">
|
||||
<div class="flex items-center gap-1">
|
||||
<button class="flex items-center gap-1 px-2 py-1.5 hover:bg-[#0085ff]/10 rounded-full group text-gray-500">
|
||||
<IconMessageCircle class="h-5 w-5 group-hover:text-[#0085ff]" />
|
||||
<span class="text-sm group-hover:text-[#0085ff]">12</span>
|
||||
</button>
|
||||
<button class="flex items-center gap-1 px-2 py-1.5 hover:bg-green-500/10 rounded-full group text-gray-500">
|
||||
<IconRepeat class="h-5 w-5 group-hover:text-green-500" />
|
||||
<span class="text-sm group-hover:text-green-500">5</span>
|
||||
</button>
|
||||
<button class="flex items-center gap-1 px-2 py-1.5 hover:bg-red-500/10 rounded-full group text-gray-500">
|
||||
<IconHeart class="h-5 w-5 group-hover:text-red-500" />
|
||||
<span class="text-sm group-hover:text-red-500">89</span>
|
||||
</button>
|
||||
</div>
|
||||
<button class="p-1.5 hover:bg-[#0085ff]/10 rounded-full group text-gray-500">
|
||||
<IconShare class="h-5 w-5 group-hover:text-[#0085ff]" />
|
||||
</button>
|
||||
</div>
|
||||
<!-- Timestamp -->
|
||||
<div class="mt-3 text-[13px] text-gray-500 dark:text-[#7b8d9e]">
|
||||
4:18 PM · Jan 21, 2026
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Engagement Actions -->
|
||||
<div class="border-t border-gray-200 dark:border-[#1e3a5f]">
|
||||
<div class="flex items-center justify-between px-3 py-1">
|
||||
<!-- Reply -->
|
||||
<button class="p-2 hover:bg-gray-100 dark:hover:bg-[#1e3a5f]/50 rounded-full group">
|
||||
<svg class="h-5 w-5 text-gray-500 dark:text-[#7b8d9e] group-hover:text-[#1185fe]"
|
||||
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Repost -->
|
||||
<button class="p-2 hover:bg-gray-100 dark:hover:bg-[#1e3a5f]/50 rounded-full group">
|
||||
<svg class="h-5 w-5 text-gray-500 dark:text-[#7b8d9e] group-hover:text-[#00ba7c]"
|
||||
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path d="M17 1l4 4-4 4" />
|
||||
<path d="M3 11V9a4 4 0 0 1 4-4h14" />
|
||||
<path d="M7 23l-4-4 4-4" />
|
||||
<path d="M21 13v2a4 4 0 0 1-4 4H3" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Like -->
|
||||
<button class="p-2 hover:bg-gray-100 dark:hover:bg-[#1e3a5f]/50 rounded-full group">
|
||||
<svg class="h-5 w-5 text-gray-500 dark:text-[#7b8d9e] group-hover:text-[#f91880]"
|
||||
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path
|
||||
d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Bookmark -->
|
||||
<button class="p-2 hover:bg-gray-100 dark:hover:bg-[#1e3a5f]/50 rounded-full group">
|
||||
<svg class="h-5 w-5 text-gray-500 dark:text-[#7b8d9e] group-hover:text-[#1185fe]"
|
||||
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Share -->
|
||||
<button class="p-2 hover:bg-gray-100 dark:hover:bg-[#1e3a5f]/50 rounded-full group">
|
||||
<svg class="h-5 w-5 text-gray-500 dark:text-[#7b8d9e] group-hover:text-[#1185fe]"
|
||||
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8" />
|
||||
<polyline points="16 6 12 2 8 6" />
|
||||
<line x1="12" y1="2" x2="12" y2="15" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- More -->
|
||||
<button class="p-2 hover:bg-gray-100 dark:hover:bg-[#1e3a5f]/50 rounded-full group">
|
||||
<svg class="h-5 w-5 text-gray-500 dark:text-[#7b8d9e] group-hover:text-[#1185fe]"
|
||||
viewBox="0 0 24 24" fill="currentColor">
|
||||
<circle cx="12" cy="12" r="1.5" />
|
||||
<circle cx="19" cy="12" r="1.5" />
|
||||
<circle cx="5" cy="12" r="1.5" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reply input -->
|
||||
<div class="px-4 py-3 border-t border-gray-200 dark:border-[#1e3a5f] flex items-center gap-3">
|
||||
<img v-if="socialAccount.avatar_url" :src="socialAccount.avatar_url" :alt="socialAccount.display_name"
|
||||
class="h-8 w-8 rounded-full object-cover shrink-0" />
|
||||
<div v-else
|
||||
class="h-8 w-8 rounded-full bg-gradient-to-br from-[#0085ff] to-[#00d4ff] flex items-center justify-center text-white text-xs font-semibold shrink-0">
|
||||
{{ socialAccount.display_name?.charAt(0) }}
|
||||
</div>
|
||||
<div class="flex-1 text-[15px] text-gray-400 dark:text-[#7b8d9e]">
|
||||
Write your reply
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer with upload and char count (only in edit mode) -->
|
||||
<div v-if="!props.readonly" class="border-t border-gray-200 dark:border-[#2e3f50] px-4 py-3 flex items-center justify-between bg-gray-50 dark:bg-[#1a2634]">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="text-xs px-2 py-1 rounded-full"
|
||||
:class="isValid ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' : 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'"
|
||||
>
|
||||
{{ validationMessage }}
|
||||
</span>
|
||||
</div>
|
||||
<label class="cursor-pointer p-2 rounded-full hover:bg-gray-200 dark:hover:bg-[#2e3f50] transition-colors">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
class="hidden"
|
||||
@change="emit('upload', $event)"
|
||||
:disabled="isUploading"
|
||||
/>
|
||||
<IconPhoto class="h-5 w-5 text-gray-500" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
|
@ -1,13 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import {
|
||||
IconDots,
|
||||
IconX,
|
||||
IconPhoto,
|
||||
IconThumbUp,
|
||||
IconMessageCircle,
|
||||
IconShare,
|
||||
IconWorld,
|
||||
} from '@tabler/icons-vue';
|
||||
import { IconDots } from '@tabler/icons-vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
interface SocialAccount {
|
||||
id: string;
|
||||
|
|
@ -28,179 +21,416 @@ interface Props {
|
|||
socialAccount: SocialAccount;
|
||||
content: string;
|
||||
media: MediaItem[];
|
||||
charCount: number;
|
||||
maxLength: number;
|
||||
isValid: boolean;
|
||||
validationMessage: string;
|
||||
isUploading?: boolean;
|
||||
readonly?: boolean;
|
||||
contentType?: string;
|
||||
charCount?: number;
|
||||
maxLength?: number;
|
||||
isValid?: boolean;
|
||||
validationMessage?: string;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:content': [value: string];
|
||||
'upload': [event: Event];
|
||||
'remove-media': [mediaId: string];
|
||||
}>();
|
||||
// Content type helpers
|
||||
const isReel = computed(() => props.contentType === 'facebook_reel');
|
||||
const isStory = computed(() => props.contentType === 'facebook_story');
|
||||
const isFeed = computed(() => !isReel.value && !isStory.value);
|
||||
|
||||
// Carousel state
|
||||
const currentIndex = ref(0);
|
||||
|
||||
// Reset carousel index when media changes
|
||||
watch(() => props.media.length, () => {
|
||||
if (currentIndex.value >= props.media.length) {
|
||||
currentIndex.value = Math.max(0, props.media.length - 1);
|
||||
}
|
||||
});
|
||||
|
||||
// Format numbers like Facebook
|
||||
const formatNumber = (num: number): string => {
|
||||
if (num >= 1000000) {
|
||||
return (num / 1000000).toFixed(1).replace(/\.0$/, '') + 'M';
|
||||
}
|
||||
if (num >= 1000) {
|
||||
return (num / 1000).toFixed(1).replace(/\.0$/, '') + 'K';
|
||||
}
|
||||
return num.toString();
|
||||
};
|
||||
|
||||
// Truncate content for display
|
||||
const truncatedContent = computed(() => {
|
||||
if (!props.content) return '';
|
||||
if (props.content.length <= 100) return props.content;
|
||||
return props.content.substring(0, 100) + '...';
|
||||
});
|
||||
|
||||
const displayName = computed(() => props.socialAccount.display_name || props.socialAccount.username);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bg-white dark:bg-[#242526] rounded-lg shadow-md overflow-hidden">
|
||||
<!-- Post Header -->
|
||||
<div class="p-4 pb-2">
|
||||
<div class="flex items-start gap-2">
|
||||
<img
|
||||
v-if="socialAccount.avatar_url"
|
||||
:src="socialAccount.avatar_url"
|
||||
:alt="socialAccount.display_name"
|
||||
class="h-10 w-10 rounded-full object-cover"
|
||||
/>
|
||||
<div v-else class="h-10 w-10 rounded-full bg-[#1877f2] flex items-center justify-center text-white font-bold">
|
||||
{{ socialAccount.display_name?.charAt(0) }}
|
||||
<div class="w-full h-full bg-[#f0f2f5] dark:bg-[#18191a] text-[#050505] dark:text-[#e4e6eb] overflow-hidden flex flex-col">
|
||||
|
||||
<!-- ==================== FEED POST ==================== -->
|
||||
<template v-if="isFeed">
|
||||
<!-- Facebook Header -->
|
||||
<div class="flex-shrink-0 h-11 bg-white dark:bg-[#242526] border-b border-[#dddfe2] dark:border-[#3e4042] flex items-center px-3">
|
||||
<svg class="h-6 w-auto text-[#1877f2]" viewBox="0 0 36 36" fill="currentColor">
|
||||
<path d="M20.18 35.87C28.872 34.632 35.87 27.1 35.87 18c0-9.882-8.01-17.89-17.9-17.89C8.09.11.08 8.12.08 18c0 8.06 5.33 14.87 12.66 17.11v-12.3h-3.81V18h3.81v-3.64c0-3.76 2.24-5.84 5.67-5.84 1.64 0 3.36.29 3.36.29v3.69h-1.89c-1.86 0-2.45 1.16-2.45 2.35V18h4.16l-.67 4.81h-3.49v12.3c1.08-.17 2.13-.44 3.14-.79l.56-.45z"/>
|
||||
</svg>
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
<!-- Search -->
|
||||
<div class="w-8 h-8 rounded-full bg-[#e4e6eb] dark:bg-[#3a3b3c] flex items-center justify-center">
|
||||
<svg class="w-4 h-4 text-[#050505] dark:text-[#e4e6eb]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<circle cx="11" cy="11" r="8"/>
|
||||
<path d="m21 21-4.35-4.35"/>
|
||||
</svg>
|
||||
</div>
|
||||
<!-- Messenger -->
|
||||
<div class="w-8 h-8 rounded-full bg-[#e4e6eb] dark:bg-[#3a3b3c] flex items-center justify-center">
|
||||
<svg class="w-4 h-4 text-[#050505] dark:text-[#e4e6eb]" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 2C6.48 2 2 6.04 2 11c0 2.76 1.36 5.22 3.5 6.87V22l3.75-2.06c.98.27 2.01.43 3.08.43h.34c5.52 0 10-4.04 10-9s-4.48-9-10-9h-.67zm1.13 12.13l-2.54-2.71-4.96 2.71 5.46-5.79 2.6 2.71 4.9-2.71-5.46 5.79z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="font-semibold text-[15px] text-gray-900 dark:text-white hover:underline cursor-pointer">
|
||||
{{ socialAccount.display_name }}
|
||||
</div>
|
||||
|
||||
<!-- Post Content -->
|
||||
<div class="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
<!-- Post Card -->
|
||||
<div class="bg-white dark:bg-[#242526] flex-1 flex flex-col min-h-0">
|
||||
<!-- Post Header -->
|
||||
<div class="flex-shrink-0 flex items-center px-3 py-2">
|
||||
<div class="flex items-center gap-2 flex-1">
|
||||
<img
|
||||
v-if="socialAccount.avatar_url"
|
||||
:src="socialAccount.avatar_url"
|
||||
:alt="displayName"
|
||||
class="w-10 h-10 rounded-full object-cover"
|
||||
/>
|
||||
<div v-else class="w-10 h-10 rounded-full bg-[#1877f2] flex items-center justify-center text-white font-bold">
|
||||
{{ displayName?.charAt(0).toUpperCase() }}
|
||||
</div>
|
||||
<div class="flex flex-col min-w-0">
|
||||
<span class="text-[13px] font-semibold leading-tight">{{ displayName }}</span>
|
||||
<div class="flex items-center gap-1 text-[11px] text-[#65676b] dark:text-[#b0b3b8]">
|
||||
<span>Just now</span>
|
||||
<span>·</span>
|
||||
<!-- Globe icon -->
|
||||
<svg class="w-3 h-3" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M8 0a8 8 0 1 0 0 16A8 8 0 0 0 8 0zm-.5 14.5v-2h1v2a6.5 6.5 0 0 1-1 0zm4.7-1.2a6.5 6.5 0 0 1-3.2.7v-1h2a1 1 0 0 0 1-1V9.5h1.5a6.5 6.5 0 0 1-1.3 3.8zM14.5 8a6.5 6.5 0 0 1-.5 2.5H12V9a1 1 0 0 0-1-1h-1V6a1 1 0 0 0-1-1H6.5V3.5h2a1 1 0 0 0 1-1V1.7a6.5 6.5 0 0 1 5 6.3zM1.5 8a6.5 6.5 0 0 1 3-5.5V4a1 1 0 0 0 1 1h1v2a1 1 0 0 0 1 1h2v3H6a1 1 0 0 0-1 1v2.5A6.5 6.5 0 0 1 1.5 8z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<IconDots class="w-5 h-5 text-[#65676b] dark:text-[#b0b3b8]" />
|
||||
</div>
|
||||
|
||||
<!-- Caption -->
|
||||
<div v-if="content" class="flex-shrink-0 px-3 pb-2">
|
||||
<p class="text-[14px] leading-snug">
|
||||
{{ truncatedContent }}
|
||||
<span v-if="content.length > 100" class="text-[#65676b] dark:text-[#b0b3b8] cursor-pointer">See more</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Post Media -->
|
||||
<div class="flex-1 relative bg-black min-h-0">
|
||||
<template v-if="media.length > 0">
|
||||
<img
|
||||
v-if="media[currentIndex]?.type === 'image'"
|
||||
:src="media[currentIndex].url"
|
||||
:alt="media[currentIndex].original_filename"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
<video
|
||||
v-else-if="media[currentIndex]"
|
||||
:src="media[currentIndex].url"
|
||||
class="w-full h-full object-cover"
|
||||
muted
|
||||
loop
|
||||
playsinline
|
||||
/>
|
||||
<!-- Multiple images indicator -->
|
||||
<div v-if="media.length > 1" class="absolute top-2 right-2 bg-black/60 text-white text-[10px] font-semibold px-2 py-0.5 rounded-full">
|
||||
{{ currentIndex + 1 }}/{{ media.length }}
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="w-full h-full flex items-center justify-center bg-[#f0f2f5] dark:bg-[#3a3b3c]">
|
||||
<svg class="w-12 h-12 text-[#bcc0c4] dark:text-[#4e4f50]" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reactions Bar -->
|
||||
<div class="flex-shrink-0 px-3 py-1.5 flex items-center justify-between border-b border-[#dddfe2] dark:border-[#3e4042]">
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="flex -space-x-1">
|
||||
<!-- Like reaction -->
|
||||
<div class="w-[18px] h-[18px] rounded-full bg-[#1877f2] flex items-center justify-center ring-2 ring-white dark:ring-[#242526]">
|
||||
<svg class="w-2.5 h-2.5 text-white" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M1 7.75v6.5c0 .41.34.75.75.75h2.5a.75.75 0 0 0 .75-.75v-6.5a.75.75 0 0 0-.75-.75h-2.5a.75.75 0 0 0-.75.75zm13.47-2.58l-3.25.01-.42-1.48a3.51 3.51 0 0 0-3.38-2.56H7.2a.75.75 0 0 0-.7.47l-1.32 3.45a1.75 1.75 0 0 0-.1.56V12c0 .97.78 1.75 1.75 1.75h4.67a1.75 1.75 0 0 0 1.7-1.34l1.3-5.21a1.75 1.75 0 0 0-1.03-2.08z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<!-- Love reaction -->
|
||||
<div class="w-[18px] h-[18px] rounded-full bg-[#f33e58] flex items-center justify-center ring-2 ring-white dark:ring-[#242526]">
|
||||
<svg class="w-2.5 h-2.5 text-white" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M8 15.25s-6.75-4.47-6.75-9A3.38 3.38 0 0 1 4.62.75 3.43 3.43 0 0 1 8 2.93 3.43 3.43 0 0 1 11.38.75a3.38 3.38 0 0 1 3.37 3.5c0 4.53-6.75 9-6.75 9z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-[12px] text-[#65676b] dark:text-[#b0b3b8] ml-1">{{ formatNumber(156) }}</span>
|
||||
</div>
|
||||
<span class="text-[12px] text-[#65676b] dark:text-[#b0b3b8]">23 comments · 5 shares</span>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex-shrink-0 flex items-center px-2 py-1 border-b border-[#dddfe2] dark:border-[#3e4042]">
|
||||
<button class="flex-1 flex items-center justify-center gap-1.5 py-1.5 text-[#65676b] dark:text-[#b0b3b8] hover:bg-[#f0f2f5] dark:hover:bg-[#3a3b3c] rounded-lg transition-colors">
|
||||
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.28a2 2 0 0 0 2-1.7l1.38-9a2 2 0 0 0-2-2.3zM7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3"/>
|
||||
</svg>
|
||||
<span class="text-[12px] font-semibold">Like</span>
|
||||
</button>
|
||||
<button class="flex-1 flex items-center justify-center gap-1.5 py-1.5 text-[#65676b] dark:text-[#b0b3b8] hover:bg-[#f0f2f5] dark:hover:bg-[#3a3b3c] rounded-lg transition-colors">
|
||||
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
||||
</svg>
|
||||
<span class="text-[12px] font-semibold">Comment</span>
|
||||
</button>
|
||||
<button class="flex-1 flex items-center justify-center gap-1.5 py-1.5 text-[#65676b] dark:text-[#b0b3b8] hover:bg-[#f0f2f5] dark:hover:bg-[#3a3b3c] rounded-lg transition-colors">
|
||||
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8"/>
|
||||
<polyline points="16 6 12 2 8 6"/>
|
||||
<line x1="12" y1="2" x2="12" y2="15"/>
|
||||
</svg>
|
||||
<span class="text-[12px] font-semibold">Share</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ==================== REELS ==================== -->
|
||||
<template v-else-if="isReel">
|
||||
<div class="relative flex-1 bg-black overflow-hidden">
|
||||
<!-- Video/Media - Full screen -->
|
||||
<div class="absolute inset-0">
|
||||
<template v-if="media.length > 0">
|
||||
<img
|
||||
v-if="media[0].type === 'image'"
|
||||
:src="media[0].url"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
<video
|
||||
v-else
|
||||
:src="media[0].url"
|
||||
class="w-full h-full object-cover"
|
||||
muted
|
||||
loop
|
||||
playsinline
|
||||
/>
|
||||
</template>
|
||||
<div v-else class="w-full h-full flex items-center justify-center bg-[#18191a]">
|
||||
<svg class="w-12 h-12 text-white/30" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Gradient overlay -->
|
||||
<div v-if="media.length > 0" class="absolute inset-x-0 bottom-0 h-48 bg-gradient-to-t from-black/70 via-black/30 to-transparent pointer-events-none z-[5]" />
|
||||
|
||||
<!-- Top Bar -->
|
||||
<div v-if="media.length > 0" class="absolute top-1 left-0 right-0 px-3 flex items-center justify-between z-10">
|
||||
<span class="text-white text-[14px] font-semibold drop-shadow-lg">Reels</span>
|
||||
<svg class="w-5 h-5 text-white drop-shadow-lg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
<path d="M16.5 7.5L21 3M21 3v4M21 3h-4"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Right Sidebar Actions -->
|
||||
<div v-if="media.length > 0" class="absolute right-2 bottom-[68px] flex flex-col items-center gap-4 z-10">
|
||||
<!-- Like -->
|
||||
<div class="flex flex-col items-center">
|
||||
<div class="w-10 h-10 flex items-center justify-center">
|
||||
<svg class="w-7 h-7 text-white drop-shadow-lg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-white text-[10px] font-semibold drop-shadow">{{ formatNumber(12453) }}</span>
|
||||
</div>
|
||||
<!-- Comment -->
|
||||
<div class="flex flex-col items-center">
|
||||
<div class="w-10 h-10 flex items-center justify-center">
|
||||
<svg class="w-7 h-7 text-white drop-shadow-lg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-white text-[10px] font-semibold drop-shadow">{{ formatNumber(892) }}</span>
|
||||
</div>
|
||||
<!-- Share -->
|
||||
<div class="flex flex-col items-center">
|
||||
<div class="w-10 h-10 flex items-center justify-center">
|
||||
<svg class="w-7 h-7 text-white drop-shadow-lg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 2L15.09 8.26L22 9.27L17 14.14L18.18 21.02L12 17.77L5.82 21.02L7 14.14L2 9.27L8.91 8.26L12 2Z" transform="rotate(90 12 12)"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<!-- More -->
|
||||
<div class="w-10 h-10 flex items-center justify-center">
|
||||
<IconDots class="w-6 h-6 text-white drop-shadow-lg" />
|
||||
</div>
|
||||
<!-- Audio spinning icon -->
|
||||
<div class="w-9 h-9 rounded-lg bg-gradient-to-br from-gray-600 to-gray-900 border border-white/30 overflow-hidden animate-[spin_3s_linear_infinite]">
|
||||
<img
|
||||
v-if="socialAccount.avatar_url"
|
||||
:src="socialAccount.avatar_url"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
<div v-else class="w-full h-full bg-[#1877f2] flex items-center justify-center text-white font-bold text-xs">
|
||||
{{ displayName?.charAt(0).toUpperCase() }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom Info -->
|
||||
<div v-if="media.length > 0" class="absolute left-0 right-14 bottom-[68px] px-3 z-10">
|
||||
<div class="flex items-center gap-2 mb-1.5">
|
||||
<img
|
||||
v-if="socialAccount.avatar_url"
|
||||
:src="socialAccount.avatar_url"
|
||||
class="w-8 h-8 rounded-full object-cover border border-white/30"
|
||||
/>
|
||||
<div v-else class="w-8 h-8 rounded-full bg-[#1877f2] flex items-center justify-center text-white font-bold text-[12px] border border-white/30">
|
||||
{{ displayName?.charAt(0).toUpperCase() }}
|
||||
</div>
|
||||
<span class="text-white text-[13px] font-semibold drop-shadow-lg">{{ displayName }}</span>
|
||||
<button class="px-3 py-1 bg-[#1877f2] text-white text-[11px] font-semibold rounded-md">
|
||||
Follow
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="content" class="text-white text-[12px] drop-shadow-lg line-clamp-2 mb-1.5">
|
||||
{{ content }}
|
||||
</p>
|
||||
<div class="flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span>Just now</span>
|
||||
<span>·</span>
|
||||
<IconWorld class="h-3 w-3" />
|
||||
<div class="flex items-center">
|
||||
<div class="flex items-center gap-1 bg-white/20 backdrop-blur-sm rounded-full px-2 py-0.5">
|
||||
<svg class="w-3 h-3 text-white" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z"/>
|
||||
</svg>
|
||||
<span class="text-white text-[10px] truncate max-w-[120px]">Original audio</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-full">
|
||||
<IconDots class="h-5 w-5 text-gray-500" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="mt-2">
|
||||
<div v-if="props.readonly" class="w-full min-h-[100px] text-[15px] text-gray-900 dark:text-[#e4e6eb] whitespace-pre-wrap">
|
||||
{{ content || 'No content' }}
|
||||
</div>
|
||||
<textarea
|
||||
v-else
|
||||
:value="content"
|
||||
@input="emit('update:content', ($event.target as HTMLTextAreaElement).value)"
|
||||
class="w-full min-h-[100px] bg-transparent border-0 p-0 text-[15px] text-gray-900 dark:text-[#e4e6eb] resize-none focus:outline-none focus:ring-0 placeholder:text-gray-500"
|
||||
placeholder="What's on your mind?"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Media -->
|
||||
<div v-if="media.length > 0">
|
||||
<div
|
||||
class="grid"
|
||||
:class="{
|
||||
'grid-cols-1': media.length === 1,
|
||||
'grid-cols-2': media.length === 2,
|
||||
'grid-cols-2': media.length >= 3,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
v-for="(item, index) in media.slice(0, 5)"
|
||||
:key="item.id"
|
||||
class="relative group overflow-hidden"
|
||||
:class="{
|
||||
'aspect-video': media.length === 1,
|
||||
'aspect-square': media.length === 2,
|
||||
'col-span-2 aspect-video': media.length >= 3 && index === 0,
|
||||
'aspect-square': media.length >= 3 && index > 0,
|
||||
}"
|
||||
>
|
||||
<img
|
||||
v-if="item.type === 'image'"
|
||||
:src="item.url"
|
||||
:alt="item.original_filename"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
<video
|
||||
v-else
|
||||
:src="item.url"
|
||||
class="w-full h-full object-cover bg-black"
|
||||
muted
|
||||
loop
|
||||
playsinline
|
||||
/>
|
||||
<button
|
||||
v-if="!props.readonly"
|
||||
type="button"
|
||||
@click="emit('remove-media', item.id)"
|
||||
class="absolute top-2 right-2 bg-black/70 text-white rounded-full p-1.5 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<IconX class="h-4 w-4" />
|
||||
</button>
|
||||
<div
|
||||
v-if="media.length > 5 && index === 4"
|
||||
class="absolute inset-0 bg-black/60 flex items-center justify-center"
|
||||
>
|
||||
<span class="text-white text-3xl font-semibold">+{{ media.length - 5 }}</span>
|
||||
<!-- Bottom Navigation Bar -->
|
||||
<div class="absolute bottom-0 left-0 right-0 h-[50px] bg-black/80 backdrop-blur-sm flex items-center justify-around px-4 z-20">
|
||||
<!-- Home -->
|
||||
<svg class="w-6 h-6 text-white/70" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
|
||||
</svg>
|
||||
<!-- Friends -->
|
||||
<svg class="w-6 h-6 text-white/70" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="9" cy="7" r="4"/>
|
||||
<path d="M2 21v-2a4 4 0 0 1 4-4h6"/>
|
||||
</svg>
|
||||
<!-- Reels - Active -->
|
||||
<svg class="w-6 h-6 text-white" viewBox="0 0 24 24" fill="currentColor">
|
||||
<rect x="2" y="2" width="20" height="20" rx="4"/>
|
||||
<polygon points="10,7 10,17 17,12" fill="black"/>
|
||||
</svg>
|
||||
<!-- Marketplace -->
|
||||
<svg class="w-6 h-6 text-white/70" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
|
||||
</svg>
|
||||
<!-- Profile -->
|
||||
<div class="w-6 h-6 rounded-full overflow-hidden ring-[1.5px] ring-white/50">
|
||||
<img
|
||||
v-if="socialAccount.avatar_url"
|
||||
:src="socialAccount.avatar_url"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
<div v-else class="w-full h-full bg-gray-600" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Reactions Summary -->
|
||||
<div class="px-4 py-2 flex items-center justify-between text-sm text-gray-500 dark:text-gray-400 border-b border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="flex -space-x-1">
|
||||
<div class="h-[18px] w-[18px] rounded-full bg-[#1877f2] flex items-center justify-center ring-2 ring-white dark:ring-[#242526]">
|
||||
<IconThumbUp class="h-2.5 w-2.5 text-white" fill="white" />
|
||||
</div>
|
||||
<div class="h-[18px] w-[18px] rounded-full bg-red-500 flex items-center justify-center ring-2 ring-white dark:ring-[#242526]">
|
||||
<span class="text-[10px]">❤️</span>
|
||||
</div>
|
||||
<div class="h-[18px] w-[18px] rounded-full bg-yellow-400 flex items-center justify-center ring-2 ring-white dark:ring-[#242526]">
|
||||
<span class="text-[10px]">😂</span>
|
||||
<!-- ==================== STORIES ==================== -->
|
||||
<template v-else-if="isStory">
|
||||
<div class="relative flex-1 bg-black overflow-hidden">
|
||||
<!-- Media - Full screen -->
|
||||
<div class="absolute inset-0">
|
||||
<template v-if="media.length > 0">
|
||||
<img
|
||||
v-if="media[0].type === 'image'"
|
||||
:src="media[0].url"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
<video
|
||||
v-else
|
||||
:src="media[0].url"
|
||||
class="w-full h-full object-cover"
|
||||
muted
|
||||
loop
|
||||
playsinline
|
||||
/>
|
||||
</template>
|
||||
<div v-else class="w-full h-full flex items-center justify-center bg-gradient-to-b from-[#1877f2]/50 to-[#833ab4]/50">
|
||||
<svg class="w-12 h-12 text-white/30" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<span class="ml-1">156</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<span>23 comments</span>
|
||||
<span>8 shares</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="px-2 py-1 flex items-center border-b border-gray-200 dark:border-gray-700">
|
||||
<button class="flex-1 flex items-center justify-center gap-2 py-2.5 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg text-gray-600 dark:text-gray-300 font-medium text-[15px]">
|
||||
<IconThumbUp class="h-5 w-5" />
|
||||
<span>Like</span>
|
||||
</button>
|
||||
<button class="flex-1 flex items-center justify-center gap-2 py-2.5 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg text-gray-600 dark:text-gray-300 font-medium text-[15px]">
|
||||
<IconMessageCircle class="h-5 w-5" />
|
||||
<span>Comment</span>
|
||||
</button>
|
||||
<button class="flex-1 flex items-center justify-center gap-2 py-2.5 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg text-gray-600 dark:text-gray-300 font-medium text-[15px]">
|
||||
<IconShare class="h-5 w-5" />
|
||||
<span>Share</span>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Progress Bars -->
|
||||
<div v-if="media.length > 0" class="absolute top-1 left-2 right-2 flex gap-0.5 z-10">
|
||||
<div class="flex-1 h-[2px] bg-white/30 rounded-full overflow-hidden">
|
||||
<div class="h-full w-1/3 bg-white rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer with upload and char count (only in edit mode) -->
|
||||
<div v-if="!props.readonly" class="px-4 py-3 flex items-center justify-between bg-gray-50 dark:bg-[#3a3b3c]">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="text-xs px-2 py-1 rounded-full"
|
||||
:class="isValid ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' : 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'"
|
||||
>
|
||||
{{ validationMessage }}
|
||||
</span>
|
||||
<!-- User Info -->
|
||||
<div v-if="media.length > 0" class="absolute top-3 left-0 right-0 px-3 flex items-center justify-between z-10">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="p-[2px] bg-[#1877f2] rounded-full">
|
||||
<img
|
||||
v-if="socialAccount.avatar_url"
|
||||
:src="socialAccount.avatar_url"
|
||||
class="w-8 h-8 rounded-full object-cover border-2 border-black"
|
||||
/>
|
||||
<div v-else class="w-8 h-8 rounded-full bg-[#1877f2] flex items-center justify-center text-white font-bold text-[11px] border-2 border-black">
|
||||
{{ displayName?.charAt(0).toUpperCase() }}
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-white text-[13px] font-semibold drop-shadow-lg">{{ displayName }}</span>
|
||||
<span class="text-white/70 text-[11px] drop-shadow">2h</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<IconDots class="w-5 h-5 text-white drop-shadow-lg" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Caption overlay (if content exists and media) -->
|
||||
<div v-if="media.length > 0 && content" class="absolute bottom-20 left-3 right-3 z-10">
|
||||
<p class="text-white text-[13px] text-center drop-shadow-lg bg-black/20 backdrop-blur-sm rounded-xl px-3 py-2 line-clamp-2">
|
||||
{{ content }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Bottom Reply Bar -->
|
||||
<div v-if="media.length > 0" class="absolute bottom-3 left-3 right-3 flex items-center gap-2 z-10">
|
||||
<div class="flex-1 bg-white/20 backdrop-blur-md rounded-full px-4 py-2.5 border border-white/20">
|
||||
<span class="text-white/80 text-[13px]">Reply to {{ displayName }}...</span>
|
||||
</div>
|
||||
<svg class="w-7 h-7 text-white drop-shadow-lg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/>
|
||||
</svg>
|
||||
<svg class="w-7 h-7 text-white drop-shadow-lg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M2 21l21-9L2 3v7l15 2-15 2v7z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<label class="cursor-pointer p-2 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*,video/*"
|
||||
multiple
|
||||
class="hidden"
|
||||
@change="emit('upload', $event)"
|
||||
:disabled="isUploading"
|
||||
/>
|
||||
<IconPhoto class="h-5 w-5 text-[#45bd62]" />
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,24 +1,19 @@
|
|||
<script setup lang="ts">
|
||||
import {
|
||||
IconDots,
|
||||
IconVideo,
|
||||
IconX,
|
||||
IconPhoto,
|
||||
IconHeart,
|
||||
IconMessageCircle,
|
||||
IconSend,
|
||||
IconBookmark,
|
||||
IconMusic,
|
||||
IconVolume,
|
||||
IconCamera,
|
||||
IconPlayerPlayFilled,
|
||||
IconPhoto,
|
||||
IconChevronLeft,
|
||||
IconChevronRight,
|
||||
} from '@tabler/icons-vue';
|
||||
import { computed } from 'vue';
|
||||
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
interface SocialAccount {
|
||||
id: string;
|
||||
|
|
@ -47,383 +42,298 @@ interface Props {
|
|||
media: MediaItem[];
|
||||
contentType?: string;
|
||||
contentTypeOptions?: ContentTypeOption[];
|
||||
charCount: number;
|
||||
maxLength: number;
|
||||
isValid: boolean;
|
||||
validationMessage: string;
|
||||
isUploading?: boolean;
|
||||
readonly?: boolean;
|
||||
charCount?: number;
|
||||
maxLength?: number;
|
||||
isValid?: boolean;
|
||||
validationMessage?: string;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
// Computed helpers for content type
|
||||
// Content type helpers
|
||||
const isReel = computed(() => props.contentType === 'instagram_reel');
|
||||
const isStory = computed(() => props.contentType === 'instagram_story');
|
||||
const hasMultipleContentTypes = computed(() => (props.contentTypeOptions?.length || 0) > 1);
|
||||
const isFeed = computed(() => !isReel.value && !isStory.value);
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:content': [value: string];
|
||||
'update:contentType': [value: string];
|
||||
'upload': [event: Event];
|
||||
'remove-media': [mediaId: string];
|
||||
}>();
|
||||
// Carousel state
|
||||
const currentIndex = ref(0);
|
||||
|
||||
// Reset carousel index when media changes
|
||||
watch(() => props.media.length, () => {
|
||||
if (currentIndex.value >= props.media.length) {
|
||||
currentIndex.value = Math.max(0, props.media.length - 1);
|
||||
}
|
||||
});
|
||||
|
||||
// Format numbers like Instagram
|
||||
const formatNumber = (num: number): string => {
|
||||
if (num >= 1000000) {
|
||||
return (num / 1000000).toFixed(1).replace(/\.0$/, '') + 'm';
|
||||
}
|
||||
if (num >= 1000) {
|
||||
return (num / 1000).toFixed(1).replace(/\.0$/, '') + 'k';
|
||||
}
|
||||
return num.toString();
|
||||
};
|
||||
|
||||
// Truncate caption for display
|
||||
const truncatedCaption = computed(() => {
|
||||
if (!props.content) return '';
|
||||
if (props.content.length <= 80) return props.content;
|
||||
return props.content.substring(0, 80) + '...';
|
||||
});
|
||||
|
||||
const username = computed(() => props.socialAccount.username || props.socialAccount.display_name);
|
||||
|
||||
// Carousel navigation
|
||||
const goToPrevious = () => {
|
||||
if (currentIndex.value > 0) {
|
||||
currentIndex.value--;
|
||||
}
|
||||
};
|
||||
|
||||
const goToNext = () => {
|
||||
if (currentIndex.value < props.media.length - 1) {
|
||||
currentIndex.value++;
|
||||
}
|
||||
};
|
||||
|
||||
const goToSlide = (index: number) => {
|
||||
currentIndex.value = index;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<!-- Content Type Selector -->
|
||||
<div v-if="hasMultipleContentTypes && !props.readonly" class="flex items-center justify-center gap-2">
|
||||
<span class="text-sm text-muted-foreground">Type:</span>
|
||||
<Select
|
||||
:model-value="contentType"
|
||||
@update:model-value="emit('update:contentType', $event)"
|
||||
>
|
||||
<SelectTrigger class="w-[140px] h-8">
|
||||
<SelectValue placeholder="Select type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="option in contentTypeOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="w-full h-full bg-white dark:bg-black text-[#262626] dark:text-[#f5f5f5] overflow-hidden flex flex-col">
|
||||
|
||||
<!-- Reel/Story Preview (vertical) -->
|
||||
<div v-if="isReel || isStory" class="mx-auto" style="max-width: 320px;">
|
||||
<div class="relative bg-black rounded-2xl overflow-hidden" style="aspect-ratio: 9/16;">
|
||||
<!-- Media Area -->
|
||||
<div v-if="media.length > 0" class="w-full h-full">
|
||||
<img
|
||||
v-if="media[0].type === 'image'"
|
||||
:src="media[0].url"
|
||||
:alt="media[0].original_filename"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
<video
|
||||
v-else
|
||||
:src="media[0].url"
|
||||
class="w-full h-full object-cover bg-black"
|
||||
muted
|
||||
loop
|
||||
playsinline
|
||||
/>
|
||||
<!-- Remove button -->
|
||||
<button
|
||||
v-if="!props.readonly"
|
||||
type="button"
|
||||
@click="emit('remove-media', media[0].id)"
|
||||
class="absolute top-3 right-3 bg-black/70 text-white rounded-full p-2 z-20"
|
||||
>
|
||||
<IconX class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div v-else-if="!props.readonly" class="w-full h-full flex flex-col items-center justify-center text-gray-400">
|
||||
<IconVideo class="h-16 w-16 mb-2" />
|
||||
<p class="text-sm">Add a {{ isReel ? 'video' : 'photo or video' }}</p>
|
||||
<label class="mt-2 cursor-pointer text-blue-500 font-medium text-sm hover:text-blue-600">
|
||||
<input
|
||||
type="file"
|
||||
:accept="isReel ? 'video/*' : 'image/*,video/*'"
|
||||
class="hidden"
|
||||
@change="emit('upload', $event)"
|
||||
:disabled="isUploading"
|
||||
/>
|
||||
Upload
|
||||
</label>
|
||||
</div>
|
||||
<div v-else class="w-full h-full flex flex-col items-center justify-center text-gray-400">
|
||||
<IconVideo class="h-16 w-16 mb-2" />
|
||||
<p class="text-sm">No media</p>
|
||||
<!-- ==================== FEED POST ==================== -->
|
||||
<template v-if="isFeed">
|
||||
<!-- Instagram Header -->
|
||||
<div class="flex-shrink-0 h-11 border-b border-[#dbdbdb] dark:border-[#262626] flex items-center px-3">
|
||||
<div class="text-lg font-semibold tracking-tight"
|
||||
style="font-family: 'Instagram Sans', -apple-system, BlinkMacSystemFont, sans-serif;">
|
||||
Instagram
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Overlay UI -->
|
||||
<div class="absolute inset-0 pointer-events-none">
|
||||
<!-- Top bar -->
|
||||
<div class="absolute top-0 left-0 right-0 p-3 flex items-center justify-between">
|
||||
<div v-if="isStory" class="flex items-center gap-2">
|
||||
<div class="p-0.5 bg-gradient-to-tr from-yellow-400 via-red-500 to-purple-600 rounded-full">
|
||||
<div class="p-0.5 bg-black rounded-full">
|
||||
<img
|
||||
v-if="socialAccount.avatar_url"
|
||||
:src="socialAccount.avatar_url"
|
||||
:alt="socialAccount.display_name"
|
||||
class="h-8 w-8 rounded-full object-cover"
|
||||
/>
|
||||
<div v-else class="h-8 w-8 rounded-full bg-gradient-to-br from-purple-500 to-pink-500 flex items-center justify-center text-white font-bold text-sm">
|
||||
{{ socialAccount.display_name?.charAt(0) }}
|
||||
<!-- Post Content - No scroll -->
|
||||
<div class="flex-1 flex flex-col min-h-0 pb-6">
|
||||
<!-- Post Header -->
|
||||
<div class="flex-shrink-0 flex items-center px-2.5 py-1.5">
|
||||
<div class="flex items-center gap-2 flex-1">
|
||||
<!-- Avatar with gradient ring -->
|
||||
<div class="p-[2px] rounded-full"
|
||||
style="background: conic-gradient(from 180deg, #feda75, #fa7e1e, #d62976, #962fbf, #4f5bd5, #feda75)">
|
||||
<div class="p-[1.5px] bg-white dark:bg-black rounded-full">
|
||||
<img v-if="socialAccount.avatar_url" :src="socialAccount.avatar_url"
|
||||
:alt="socialAccount.display_name" class="w-7 h-7 rounded-full object-cover" />
|
||||
<div v-else
|
||||
class="w-7 h-7 rounded-full bg-gradient-to-br from-[#833ab4] to-[#fd1d1d] flex items-center justify-center text-white font-semibold text-[10px]">
|
||||
{{ socialAccount.display_name?.charAt(0).toUpperCase() }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-white text-sm font-semibold">{{ socialAccount.username || socialAccount.display_name }}</span>
|
||||
</div>
|
||||
<div v-if="isReel" class="bg-black/50 text-white text-xs px-2 py-1 rounded flex items-center gap-1">
|
||||
<IconVideo class="h-3 w-3" />
|
||||
Reel
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right sidebar actions (Reels) -->
|
||||
<div v-if="isReel" class="absolute bottom-20 right-3 flex flex-col items-center gap-4">
|
||||
<button class="flex flex-col items-center gap-1">
|
||||
<IconHeart class="h-7 w-7 text-white drop-shadow" />
|
||||
<span class="text-white text-xs drop-shadow">1.2K</span>
|
||||
</button>
|
||||
<button class="flex flex-col items-center gap-1">
|
||||
<IconMessageCircle class="h-7 w-7 text-white drop-shadow" />
|
||||
<span class="text-white text-xs drop-shadow">89</span>
|
||||
</button>
|
||||
<button class="flex flex-col items-center gap-1">
|
||||
<IconSend class="h-7 w-7 text-white drop-shadow" />
|
||||
</button>
|
||||
<button class="flex flex-col items-center gap-1">
|
||||
<IconDots class="h-7 w-7 text-white drop-shadow" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Bottom content (Reels) -->
|
||||
<div v-if="isReel" class="absolute bottom-3 left-3 right-14">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<img
|
||||
v-if="socialAccount.avatar_url"
|
||||
:src="socialAccount.avatar_url"
|
||||
:alt="socialAccount.display_name"
|
||||
class="h-8 w-8 rounded-full object-cover border-2 border-white"
|
||||
/>
|
||||
<div v-else class="h-8 w-8 rounded-full bg-gradient-to-br from-purple-500 to-pink-500 flex items-center justify-center text-white font-bold text-sm border-2 border-white">
|
||||
{{ socialAccount.display_name?.charAt(0) }}
|
||||
</div>
|
||||
<span class="text-white text-sm font-semibold drop-shadow">{{ socialAccount.username || socialAccount.display_name }}</span>
|
||||
<button class="ml-1 px-3 py-1 bg-white/20 text-white text-xs font-semibold rounded">Follow</button>
|
||||
</div>
|
||||
<p v-if="content" class="text-white text-sm drop-shadow line-clamp-2">{{ content }}</p>
|
||||
<div class="flex items-center gap-1 mt-2 text-white/80 text-xs">
|
||||
<IconMusic class="h-3 w-3" />
|
||||
<span class="truncate">Original audio</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Story reply bar -->
|
||||
<div v-if="isStory" class="absolute bottom-3 left-3 right-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex-1 bg-white/20 backdrop-blur rounded-full px-4 py-2 text-white/80 text-sm">
|
||||
Send message
|
||||
</div>
|
||||
<IconHeart class="h-6 w-6 text-white" />
|
||||
<IconSend class="h-6 w-6 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Caption input for Reels -->
|
||||
<div v-if="isReel" class="mt-4 p-4 bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 block">Caption</label>
|
||||
<div v-if="props.readonly" class="w-full min-h-[80px] bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-3 text-sm text-gray-900 dark:text-white whitespace-pre-wrap">
|
||||
{{ content || 'No content' }}
|
||||
</div>
|
||||
<textarea
|
||||
v-else
|
||||
:value="content"
|
||||
@input="emit('update:content', ($event.target as HTMLTextAreaElement).value)"
|
||||
class="w-full min-h-[80px] bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-3 text-sm text-gray-900 dark:text-white resize-none focus:outline-none focus:ring-2 focus:ring-pink-500 placeholder:text-gray-500"
|
||||
placeholder="Write a caption..."
|
||||
/>
|
||||
<div v-if="!props.readonly" class="flex items-center justify-between mt-2">
|
||||
<span
|
||||
class="text-xs px-2 py-1 rounded-full"
|
||||
:class="isValid ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' : 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'"
|
||||
>
|
||||
{{ validationMessage }}
|
||||
</span>
|
||||
<label class="cursor-pointer p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors">
|
||||
<input
|
||||
type="file"
|
||||
accept="video/*"
|
||||
class="hidden"
|
||||
@change="emit('upload', $event)"
|
||||
:disabled="isUploading"
|
||||
/>
|
||||
<IconPhoto class="h-5 w-5 text-gray-500" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Story note -->
|
||||
<div v-if="isStory" class="mt-4 p-4 bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 text-center">Stories disappear after 24 hours</p>
|
||||
<div v-if="!props.readonly" class="flex items-center justify-between mt-2">
|
||||
<span
|
||||
class="text-xs px-2 py-1 rounded-full"
|
||||
:class="isValid ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' : 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'"
|
||||
>
|
||||
{{ validationMessage }}
|
||||
</span>
|
||||
<label class="cursor-pointer p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*,video/*"
|
||||
class="hidden"
|
||||
@change="emit('upload', $event)"
|
||||
:disabled="isUploading"
|
||||
/>
|
||||
<IconPhoto class="h-5 w-5 text-gray-500" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Feed Post Preview (original layout) -->
|
||||
<div v-else class="bg-white dark:bg-black rounded-lg overflow-hidden border border-gray-200 dark:border-gray-800">
|
||||
<!-- Post Header -->
|
||||
<div class="px-3 py-2.5 flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Avatar with gradient ring -->
|
||||
<div class="p-0.5 bg-gradient-to-tr from-yellow-400 via-red-500 to-purple-600 rounded-full">
|
||||
<div class="p-0.5 bg-white dark:bg-black rounded-full">
|
||||
<img
|
||||
v-if="socialAccount.avatar_url"
|
||||
:src="socialAccount.avatar_url"
|
||||
:alt="socialAccount.display_name"
|
||||
class="h-8 w-8 rounded-full object-cover"
|
||||
/>
|
||||
<div v-else class="h-8 w-8 rounded-full bg-gradient-to-br from-purple-500 to-pink-500 flex items-center justify-center text-white font-bold text-sm">
|
||||
{{ socialAccount.display_name?.charAt(0) }}
|
||||
<div class="flex flex-col min-w-0">
|
||||
<span class="text-[12px] font-semibold leading-tight truncate">{{ username }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<IconDots class="w-4 h-4 flex-shrink-0" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-semibold text-sm text-gray-900 dark:text-white">
|
||||
{{ socialAccount.username || socialAccount.display_name }}
|
||||
|
||||
<!-- Post Media - Fixed height to prevent overflow -->
|
||||
<div class="flex-1 relative bg-black min-h-0">
|
||||
<template v-if="media.length > 0">
|
||||
<img v-if="media[currentIndex]?.type === 'image'" :src="media[currentIndex].url"
|
||||
:alt="media[currentIndex].original_filename" class="w-full h-full object-cover" />
|
||||
<video v-else-if="media[currentIndex]" :src="media[currentIndex].url"
|
||||
class="w-full h-full object-cover" muted loop playsinline />
|
||||
|
||||
<!-- Carousel navigation arrows -->
|
||||
<template v-if="media.length > 1">
|
||||
<!-- Previous arrow -->
|
||||
<button v-if="currentIndex > 0" @click="goToPrevious"
|
||||
class="absolute left-1.5 top-1/2 -translate-y-1/2 w-6 h-6 bg-white/90 rounded-full flex items-center justify-center shadow-sm hover:bg-white transition-colors z-10">
|
||||
<IconChevronLeft class="w-4 h-4 text-[#262626]" />
|
||||
</button>
|
||||
<!-- Next arrow -->
|
||||
<button v-if="currentIndex < media.length - 1" @click="goToNext"
|
||||
class="absolute right-1.5 top-1/2 -translate-y-1/2 w-6 h-6 bg-white/90 rounded-full flex items-center justify-center shadow-sm hover:bg-white transition-colors z-10">
|
||||
<IconChevronRight class="w-4 h-4 text-[#262626]" />
|
||||
</button>
|
||||
<!-- Carousel dots -->
|
||||
<div class="absolute bottom-2 left-1/2 -translate-x-1/2 flex gap-1">
|
||||
<button v-for="(_, i) in media" :key="i" @click="goToSlide(i)"
|
||||
class="w-[6px] h-[6px] rounded-full transition-colors"
|
||||
:class="i === currentIndex ? 'bg-[#0095f6]' : 'bg-white/50 hover:bg-white/70'" />
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
<div v-else class="w-full h-full flex items-center justify-center bg-[#fafafa] dark:bg-[#121212]">
|
||||
<IconPhoto class="w-12 h-12 text-[#dbdbdb] dark:text-[#363636]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex-shrink-0 flex items-center justify-between px-2.5 py-1.5">
|
||||
<div class="flex items-center gap-3">
|
||||
<IconHeart class="w-5 h-5" />
|
||||
<IconMessageCircle class="w-5 h-5 -scale-x-100" />
|
||||
<IconSend class="w-5 h-5 -rotate-12" />
|
||||
</div>
|
||||
<IconBookmark class="w-5 h-5" />
|
||||
</div>
|
||||
|
||||
<!-- Likes -->
|
||||
<div class="flex-shrink-0 px-2.5">
|
||||
<span class="text-[12px] font-semibold">{{ formatNumber(1234) }} likes</span>
|
||||
</div>
|
||||
|
||||
<!-- Caption -->
|
||||
<div v-if="content" class="flex-shrink-0 px-2.5 py-0.5">
|
||||
<p class="text-[12px] line-clamp-2">
|
||||
<span class="font-semibold">{{ username }}</span>
|
||||
<span class="ml-1">{{ content }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button class="p-1">
|
||||
<IconDots class="h-5 w-5 text-gray-900 dark:text-white" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Media (required for Instagram) -->
|
||||
<div class="relative aspect-square bg-gray-100 dark:bg-gray-900 group">
|
||||
<div v-if="media.length > 0" class="w-full h-full">
|
||||
<img
|
||||
v-if="media[0].type === 'image'"
|
||||
:src="media[0].url"
|
||||
:alt="media[0].original_filename"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
<video
|
||||
v-else
|
||||
:src="media[0].url"
|
||||
class="w-full h-full object-cover bg-black"
|
||||
muted
|
||||
loop
|
||||
playsinline
|
||||
/>
|
||||
<!-- Remove button -->
|
||||
<button
|
||||
v-if="!props.readonly"
|
||||
type="button"
|
||||
@click="emit('remove-media', media[0].id)"
|
||||
class="absolute top-3 right-3 bg-black/70 text-white rounded-full p-2 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<IconX class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div v-else-if="!props.readonly" class="w-full h-full flex flex-col items-center justify-center text-gray-400">
|
||||
<IconPhoto class="h-16 w-16 mb-2" />
|
||||
<p class="text-sm">Add a photo or video</p>
|
||||
<label class="mt-2 cursor-pointer text-blue-500 font-medium text-sm hover:text-blue-600">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*,video/*"
|
||||
class="hidden"
|
||||
@change="emit('upload', $event)"
|
||||
:disabled="isUploading"
|
||||
/>
|
||||
Upload
|
||||
</label>
|
||||
</div>
|
||||
<div v-else class="w-full h-full flex flex-col items-center justify-center text-gray-400">
|
||||
<IconPhoto class="h-16 w-16 mb-2" />
|
||||
<p class="text-sm">No media</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="px-3 py-2 flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<button class="hover:opacity-60 transition-opacity">
|
||||
<IconHeart class="h-6 w-6 text-gray-900 dark:text-white" />
|
||||
</button>
|
||||
<button class="hover:opacity-60 transition-opacity">
|
||||
<IconMessageCircle class="h-6 w-6 text-gray-900 dark:text-white" />
|
||||
</button>
|
||||
<button class="hover:opacity-60 transition-opacity">
|
||||
<IconSend class="h-6 w-6 text-gray-900 dark:text-white" />
|
||||
</button>
|
||||
</div>
|
||||
<button class="hover:opacity-60 transition-opacity">
|
||||
<IconBookmark class="h-6 w-6 text-gray-900 dark:text-white" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Likes -->
|
||||
<div class="px-3 pb-1">
|
||||
<p class="font-semibold text-sm text-gray-900 dark:text-white">1,234 likes</p>
|
||||
</div>
|
||||
|
||||
<!-- Caption -->
|
||||
<div class="px-3 pb-2">
|
||||
<div class="text-sm">
|
||||
<span class="font-semibold text-gray-900 dark:text-white mr-1">
|
||||
{{ socialAccount.username || socialAccount.display_name }}
|
||||
</span>
|
||||
<div v-if="props.readonly" class="w-full min-h-[60px] text-sm text-gray-900 dark:text-white whitespace-pre-wrap">
|
||||
{{ content || 'No content' }}
|
||||
<!-- ==================== REELS ==================== -->
|
||||
<template v-else-if="isReel">
|
||||
<div class="relative flex-1 bg-[#fafafa] dark:bg-black overflow-hidden">
|
||||
<!-- Video/Media - Full screen -->
|
||||
<div class="absolute inset-0">
|
||||
<template v-if="media.length > 0">
|
||||
<img v-if="media[0].type === 'image'" :src="media[0].url" class="w-full h-full object-cover" />
|
||||
<video v-else :src="media[0].url" class="w-full h-full object-cover" muted loop playsinline />
|
||||
</template>
|
||||
<div v-else class="w-full h-full flex items-center justify-center">
|
||||
<IconPlayerPlayFilled class="w-12 h-12 text-[#dbdbdb] dark:text-white/30" />
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
v-else
|
||||
:value="content"
|
||||
@input="emit('update:content', ($event.target as HTMLTextAreaElement).value)"
|
||||
class="w-full min-h-[60px] bg-transparent border-0 p-0 text-sm text-gray-900 dark:text-white resize-none focus:outline-none focus:ring-0 placeholder:text-gray-500"
|
||||
placeholder="Write a caption..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Timestamp -->
|
||||
<div class="px-3 pb-3">
|
||||
<p class="text-[10px] text-gray-500 uppercase tracking-wide">Just now</p>
|
||||
</div>
|
||||
<!-- Top Bar - below status bar -->
|
||||
<div class="absolute top-1 left-0 right-0 px-3 flex items-center justify-between z-10">
|
||||
<span class="text-[#262626] dark:text-white text-[14px] font-semibold"
|
||||
:class="media.length > 0 ? 'drop-shadow-lg dark:drop-shadow-lg text-white' : ''">Reels</span>
|
||||
<IconCamera class="w-5 h-5"
|
||||
:class="media.length > 0 ? 'text-white drop-shadow-lg' : 'text-[#262626] dark:text-white'" />
|
||||
</div>
|
||||
|
||||
<!-- Footer with char count (only in edit mode) -->
|
||||
<div v-if="!props.readonly" class="border-t border-gray-200 dark:border-gray-800 px-4 py-3 flex items-center justify-between bg-gray-50 dark:bg-gray-900/50">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="text-xs px-2 py-1 rounded-full"
|
||||
:class="isValid ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' : 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'"
|
||||
>
|
||||
{{ validationMessage }}
|
||||
</span>
|
||||
<!-- Right Sidebar Actions (only show when media exists) -->
|
||||
<div v-if="media.length > 0" class="absolute right-2 bottom-3 flex flex-col items-center gap-4 z-10">
|
||||
<div class="flex flex-col items-center">
|
||||
<IconHeart class="w-6 h-6 text-white drop-shadow-lg" />
|
||||
<span class="text-white text-[10px] font-semibold mt-0.5 drop-shadow">{{ formatNumber(12453)
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center">
|
||||
<IconMessageCircle class="w-6 h-6 text-white drop-shadow-lg" />
|
||||
<span class="text-white text-[10px] font-semibold mt-0.5 drop-shadow">{{ formatNumber(892)
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center mb-4">
|
||||
<IconSend class="w-6 h-6 text-white drop-shadow-lg -rotate-12" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom Info (only show when media exists) -->
|
||||
<div v-if="media.length > 0" class="absolute left-0 right-10 bottom-3 px-3 z-10">
|
||||
<div class="flex items-center gap-2 mb-1.5">
|
||||
<img v-if="socialAccount.avatar_url" :src="socialAccount.avatar_url"
|
||||
class="w-7 h-7 rounded-full object-cover border border-white/30" />
|
||||
<div v-else
|
||||
class="w-7 h-7 rounded-full bg-gradient-to-br from-[#833ab4] to-[#fd1d1d] flex items-center justify-center text-white font-semibold text-[10px] border border-white/30">
|
||||
{{ socialAccount.display_name?.charAt(0).toUpperCase() }}
|
||||
</div>
|
||||
<span class="text-white text-[12px] font-semibold drop-shadow-lg">{{ username }}</span>
|
||||
<button class="px-2 py-0.5 border border-white/70 rounded text-white text-[10px] font-semibold">
|
||||
Follow
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="content" class="text-white text-[11px] drop-shadow-lg line-clamp-2 mb-1.5">
|
||||
{{ truncatedCaption }}
|
||||
</p>
|
||||
<div class="flex items-center">
|
||||
<div class="flex items-center gap-1 bg-white/20 backdrop-blur-sm rounded-full px-2 py-0.5">
|
||||
<IconMusic class="w-2.5 h-2.5 text-white" />
|
||||
<span class="text-white text-[9px] truncate max-w-[100px]">Original audio</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<label v-if="media.length > 0" class="cursor-pointer p-2 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-800 transition-colors">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*,video/*"
|
||||
multiple
|
||||
class="hidden"
|
||||
@change="emit('upload', $event)"
|
||||
:disabled="isUploading"
|
||||
/>
|
||||
<IconPhoto class="h-5 w-5 text-gray-500" />
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ==================== STORIES ==================== -->
|
||||
<template v-else-if="isStory">
|
||||
<div class="relative flex-1 bg-[#fafafa] dark:bg-black overflow-hidden">
|
||||
<!-- Media - Full screen -->
|
||||
<div class="absolute inset-0">
|
||||
<template v-if="media.length > 0">
|
||||
<img v-if="media[0].type === 'image'" :src="media[0].url" class="w-full h-full object-cover" />
|
||||
<video v-else :src="media[0].url" class="w-full h-full object-cover" muted loop playsinline />
|
||||
</template>
|
||||
<div v-else class="w-full h-full flex items-center justify-center">
|
||||
<IconPhoto class="w-12 h-12 text-[#dbdbdb] dark:text-white/30" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress Bars - below status bar -->
|
||||
<div class="absolute top-0.5 left-2 right-2 flex gap-0.5 z-10">
|
||||
<div class="flex-1 h-[2px] rounded-full overflow-hidden"
|
||||
:class="media.length > 0 ? 'bg-white/30' : 'bg-[#dbdbdb] dark:bg-white/30'">
|
||||
<div class="h-full w-1/3 rounded-full"
|
||||
:class="media.length > 0 ? 'bg-white' : 'bg-[#262626] dark:bg-white'" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User Info -->
|
||||
<div class="absolute top-3 left-0 right-0 px-2.5 flex items-center justify-between z-10">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="p-[2px] rounded-full"
|
||||
style="background: conic-gradient(from 180deg, #feda75, #fa7e1e, #d62976, #962fbf, #4f5bd5, #feda75)">
|
||||
<div class="p-[1.5px] bg-white dark:bg-black rounded-full">
|
||||
<img v-if="socialAccount.avatar_url" :src="socialAccount.avatar_url"
|
||||
class="w-7 h-7 rounded-full object-cover" />
|
||||
<div v-else
|
||||
class="w-7 h-7 rounded-full bg-gradient-to-br from-[#833ab4] to-[#fd1d1d] flex items-center justify-center text-white font-semibold text-[10px]">
|
||||
{{ socialAccount.display_name?.charAt(0).toUpperCase() }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-[12px] font-semibold"
|
||||
:class="media.length > 0 ? 'text-white drop-shadow-lg' : 'text-[#262626] dark:text-white'">{{
|
||||
username
|
||||
}}</span>
|
||||
<span class="text-[10px]"
|
||||
:class="media.length > 0 ? 'text-white/70 drop-shadow' : 'text-[#737373] dark:text-white/70'">2h</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<IconVolume class="w-4 h-4"
|
||||
:class="media.length > 0 ? 'text-white drop-shadow-lg' : 'text-[#262626] dark:text-white'" />
|
||||
<IconDots class="w-4 h-4"
|
||||
:class="media.length > 0 ? 'text-white drop-shadow-lg' : 'text-[#262626] dark:text-white'" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Caption overlay (if content exists, only show when media) -->
|
||||
<div v-if="content && media.length > 0" class="absolute bottom-14 left-2.5 right-2.5 z-10">
|
||||
<p
|
||||
class="text-white text-[12px] text-center drop-shadow-lg bg-black/20 backdrop-blur-sm rounded-lg px-2.5 py-1.5 line-clamp-2">
|
||||
{{ content }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Bottom Reply Bar (only show when media exists) -->
|
||||
<div v-if="media.length > 0"
|
||||
class="absolute bottom-2.5 left-2.5 right-2.5 flex items-center gap-2 z-10">
|
||||
<div class="flex-1 bg-white/20 backdrop-blur-md rounded-full px-3 py-2 border border-white/20">
|
||||
<span class="text-white/80 text-[12px]">Send message</span>
|
||||
</div>
|
||||
<IconHeart class="w-6 h-6 text-white drop-shadow-lg" />
|
||||
<IconSend class="w-6 h-6 text-white drop-shadow-lg -rotate-12" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
|
@ -1,16 +1,4 @@
|
|||
<script setup lang="ts">
|
||||
import {
|
||||
IconWorld,
|
||||
IconThumbUp,
|
||||
IconMessageCircle,
|
||||
IconRepeat,
|
||||
IconSend,
|
||||
IconDots,
|
||||
IconX,
|
||||
IconPhoto,
|
||||
IconHeart,
|
||||
} from '@tabler/icons-vue';
|
||||
|
||||
interface SocialAccount {
|
||||
id: string;
|
||||
platform: string;
|
||||
|
|
@ -30,181 +18,127 @@ interface Props {
|
|||
socialAccount: SocialAccount;
|
||||
content: string;
|
||||
media: MediaItem[];
|
||||
charCount: number;
|
||||
maxLength: number;
|
||||
isValid: boolean;
|
||||
validationMessage: string;
|
||||
isUploading?: boolean;
|
||||
readonly?: boolean;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:content': [value: string];
|
||||
'upload': [event: Event];
|
||||
'remove-media': [mediaId: string];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bg-white dark:bg-[#1b1f23] rounded-lg shadow-lg overflow-hidden border border-gray-200 dark:border-gray-700">
|
||||
<!-- Post Header -->
|
||||
<div class="p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<img
|
||||
v-if="socialAccount.avatar_url"
|
||||
:src="socialAccount.avatar_url"
|
||||
:alt="socialAccount.display_name"
|
||||
class="h-12 w-12 rounded-full object-cover"
|
||||
/>
|
||||
<div v-else class="h-12 w-12 rounded-full bg-[#0a66c2] flex items-center justify-center text-white font-semibold">
|
||||
{{ socialAccount.display_name?.charAt(0) }}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="font-semibold text-sm text-gray-900 dark:text-white hover:text-[#0a66c2] hover:underline cursor-pointer">
|
||||
{{ socialAccount.display_name }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 truncate">
|
||||
Software Engineer
|
||||
</p>
|
||||
<div class="flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span>now</span>
|
||||
<span>·</span>
|
||||
<IconWorld class="h-3 w-3" />
|
||||
<div
|
||||
class="w-full h-full bg-white dark:bg-[#1b1f23] text-[#000000e6] dark:text-[#ffffffe6] overflow-hidden flex flex-col">
|
||||
<!-- Post Content -->
|
||||
<div class="flex-1 overflow-y-auto mt-4">
|
||||
<!-- Post Header -->
|
||||
<div class="px-4 pb-3">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="relative flex-shrink-0">
|
||||
<img v-if="socialAccount.avatar_url" :src="socialAccount.avatar_url"
|
||||
:alt="socialAccount.display_name" class="h-12 w-12 rounded-full object-cover" />
|
||||
<div v-else
|
||||
class="h-12 w-12 rounded-full bg-[#0a66c2] flex items-center justify-center text-white font-semibold text-lg">
|
||||
{{ socialAccount.display_name?.charAt(0).toUpperCase() }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-1">
|
||||
<p class="font-semibold text-[15px] text-[#000000e6] dark:text-[#ffffffe6]">
|
||||
{{ socialAccount.display_name }}
|
||||
</p>
|
||||
<span class="text-[14px] text-[#00000099] dark:text-[#ffffff99]">• 1st</span>
|
||||
</div>
|
||||
<p class="text-[13px] text-[#00000099] dark:text-[#ffffff99] leading-[18px] line-clamp-1">
|
||||
CEO
|
||||
</p>
|
||||
<div class="flex items-center gap-1 text-[13px] text-[#00000099] dark:text-[#ffffff99]">
|
||||
<span>1h</span>
|
||||
<span>•</span>
|
||||
<svg class="h-3.5 w-3.5" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path
|
||||
d="M8 0a8 8 0 1 0 0 16A8 8 0 0 0 8 0zm-.5 14.5v-2h1v2a6.5 6.5 0 0 1-1 0zm4.7-1.2a6.5 6.5 0 0 1-3.2.7v-1h2a1 1 0 0 0 1-1V9.5h1.5a6.5 6.5 0 0 1-1.3 3.8zM14.5 8a6.5 6.5 0 0 1-.5 2.5H12V9a1 1 0 0 0-1-1h-1V6a1 1 0 0 0-1-1H6.5V3.5h2a1 1 0 0 0 1-1V1.7a6.5 6.5 0 0 1 5 6.3zM1.5 8a6.5 6.5 0 0 1 3-5.5V4a1 1 0 0 0 1 1h1v2a1 1 0 0 0 1 1h2v3H6a1 1 0 0 0-1 1v2.5A6.5 6.5 0 0 1 1.5 8z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-full">
|
||||
<IconDots class="h-5 w-5 text-gray-500" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="mt-3">
|
||||
<div v-if="props.readonly" class="w-full min-h-[60px] text-sm text-gray-900 dark:text-white whitespace-pre-wrap">
|
||||
{{ content || 'No content' }}
|
||||
<div v-if="content" class="px-4 pb-3">
|
||||
<div class="text-[15px] text-[#000000e6] dark:text-[#ffffffe6] whitespace-pre-wrap leading-[22px]">
|
||||
{{ content }}
|
||||
</div>
|
||||
<textarea
|
||||
v-else
|
||||
:value="content"
|
||||
@input="emit('update:content', ($event.target as HTMLTextAreaElement).value)"
|
||||
class="w-full min-h-[120px] bg-transparent border-0 p-0 text-sm text-gray-900 dark:text-white resize-none focus:outline-none focus:ring-0 placeholder:text-gray-500"
|
||||
placeholder="What do you want to talk about?"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Media -->
|
||||
<div v-if="media.length > 0" class="border-t border-gray-200 dark:border-gray-700">
|
||||
<div
|
||||
class="grid"
|
||||
:class="{
|
||||
<!-- Media -->
|
||||
<div v-if="media.length > 0">
|
||||
<div class="grid" :class="{
|
||||
'grid-cols-1': media.length === 1,
|
||||
'grid-cols-2': media.length >= 2,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
v-for="(item, index) in media.slice(0, 4)"
|
||||
:key="item.id"
|
||||
class="relative group aspect-square overflow-hidden"
|
||||
:class="{
|
||||
'col-span-2 aspect-video': media.length === 1,
|
||||
'row-span-2': media.length === 3 && index === 0,
|
||||
}"
|
||||
>
|
||||
<img
|
||||
v-if="item.type === 'image'"
|
||||
:src="item.url"
|
||||
:alt="item.original_filename"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
<video
|
||||
v-else
|
||||
:src="item.url"
|
||||
class="w-full h-full object-cover bg-black"
|
||||
muted
|
||||
loop
|
||||
playsinline
|
||||
/>
|
||||
<button
|
||||
v-if="!props.readonly"
|
||||
type="button"
|
||||
@click="emit('remove-media', item.id)"
|
||||
class="absolute top-2 right-2 bg-black/70 text-white rounded-full p-1.5 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<IconX class="h-4 w-4" />
|
||||
</button>
|
||||
<div
|
||||
v-if="media.length > 4 && index === 3"
|
||||
class="absolute inset-0 bg-black/60 flex items-center justify-center"
|
||||
>
|
||||
<span class="text-white text-2xl font-semibold">+{{ media.length - 4 }}</span>
|
||||
}">
|
||||
<div v-for="(item, index) in media.slice(0, 4)" :key="item.id"
|
||||
class="relative overflow-hidden" :class="{
|
||||
'col-span-2': media.length === 1,
|
||||
'row-span-2': media.length === 3 && index === 0,
|
||||
'aspect-square': media.length > 1,
|
||||
}">
|
||||
<img v-if="item.type === 'image'" :src="item.url" :alt="item.original_filename"
|
||||
class="w-full h-full object-cover" />
|
||||
<video v-else :src="item.url" class="w-full h-full object-cover bg-black" muted loop
|
||||
playsinline />
|
||||
<div v-if="media.length > 4 && index === 3"
|
||||
class="absolute inset-0 bg-black/70 flex items-center justify-center">
|
||||
<span class="text-white text-3xl font-semibold">+{{ media.length - 4 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Engagement Stats -->
|
||||
<div class="px-4 py-2 flex items-center justify-between text-xs text-gray-500 dark:text-gray-400 border-t border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="flex -space-x-1">
|
||||
<div class="h-4 w-4 rounded-full bg-[#0a66c2] flex items-center justify-center">
|
||||
<IconThumbUp class="h-2.5 w-2.5 text-white" />
|
||||
</div>
|
||||
<div class="h-4 w-4 rounded-full bg-red-500 flex items-center justify-center">
|
||||
<IconHeart class="h-2.5 w-2.5 text-white" />
|
||||
<!-- Engagement Stats -->
|
||||
<div
|
||||
class="px-4 py-2.5 flex items-center justify-between text-[13px] text-[#00000099] dark:text-[#ffffff99]">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div class="flex -space-x-0.5">
|
||||
<!-- Like (blue) -->
|
||||
<div class="h-[18px] w-[18px] rounded-full bg-[#0a66c2] flex items-center justify-center">
|
||||
<svg class="w-2.5 h-2.5 text-white" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path
|
||||
d="M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.28a2 2 0 0 0 2-1.7l1.38-9a2 2 0 0 0-2-2.3zM7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3" />
|
||||
</svg>
|
||||
</div>
|
||||
<!-- Love (red) -->
|
||||
<div class="h-[18px] w-[18px] rounded-full bg-[#df704d] flex items-center justify-center">
|
||||
<svg class="w-2.5 h-2.5 text-white" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path
|
||||
d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<span>Tim and 8 others</span>
|
||||
</div>
|
||||
<span>24</span>
|
||||
<span>1 comment</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span>3 comments</span>
|
||||
<span>·</span>
|
||||
<span>1 repost</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="px-2 py-1 flex items-center justify-between border-t border-gray-200 dark:border-gray-700">
|
||||
<button class="flex-1 flex items-center justify-center gap-2 py-3 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg text-gray-600 dark:text-gray-400 font-medium text-sm">
|
||||
<IconThumbUp class="h-5 w-5" />
|
||||
<span class="hidden sm:inline">Like</span>
|
||||
</button>
|
||||
<button class="flex-1 flex items-center justify-center gap-2 py-3 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg text-gray-600 dark:text-gray-400 font-medium text-sm">
|
||||
<IconMessageCircle class="h-5 w-5" />
|
||||
<span class="hidden sm:inline">Comment</span>
|
||||
</button>
|
||||
<button class="flex-1 flex items-center justify-center gap-2 py-3 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg text-gray-600 dark:text-gray-400 font-medium text-sm">
|
||||
<IconRepeat class="h-5 w-5" />
|
||||
<span class="hidden sm:inline">Repost</span>
|
||||
</button>
|
||||
<button class="flex-1 flex items-center justify-center gap-2 py-3 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg text-gray-600 dark:text-gray-400 font-medium text-sm">
|
||||
<IconSend class="h-5 w-5" />
|
||||
<span class="hidden sm:inline">Send</span>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Divider -->
|
||||
<div class="border-t border-[#e0dfdc] dark:border-[#38434f]" />
|
||||
|
||||
<!-- Footer with upload and char count (only in edit mode) -->
|
||||
<div v-if="!props.readonly" class="border-t border-gray-200 dark:border-gray-700 px-4 py-3 flex items-center justify-between bg-gray-50 dark:bg-gray-800/50">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="text-xs px-2 py-1 rounded-full"
|
||||
:class="isValid ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' : 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'"
|
||||
>
|
||||
{{ validationMessage }}
|
||||
</span>
|
||||
<!-- Comment Input -->
|
||||
<div class="px-2 py-4 flex items-center gap-2">
|
||||
<img v-if="socialAccount.avatar_url" :src="socialAccount.avatar_url" :alt="socialAccount.display_name"
|
||||
class="h-8 w-8 rounded-full object-cover shrink-0" />
|
||||
<div v-else
|
||||
class="h-8 w-8 rounded-full bg-[#0a66c2] flex items-center justify-center text-white font-semibold text-sm shrink-0">
|
||||
{{ socialAccount.display_name?.charAt(0).toUpperCase() }}
|
||||
</div>
|
||||
<div class="flex-1 bg-[#f4f2ee] dark:bg-[#38434f] rounded-full px-4 py-2">
|
||||
<span class="text-[14px] text-[#00000099] dark:text-[#ffffff99]">Leave your thoughts...</span>
|
||||
</div>
|
||||
<button class="p-1">
|
||||
<svg class="w-6 h-6 text-[#00000099] dark:text-[#ffffff99]" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path d="M16 8v5a3 3 0 0 0 6 0v-1a10 10 0 1 0-3.92 7.94" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<label class="cursor-pointer p-2 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*,video/*"
|
||||
multiple
|
||||
class="hidden"
|
||||
@change="emit('upload', $event)"
|
||||
:disabled="isUploading"
|
||||
/>
|
||||
<IconPhoto class="h-5 w-5 text-gray-500" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
|
@ -1,15 +1,4 @@
|
|||
<script setup lang="ts">
|
||||
import {
|
||||
IconDots,
|
||||
IconX,
|
||||
IconPhoto,
|
||||
IconHeart,
|
||||
IconMessageCircle,
|
||||
IconRepeat,
|
||||
IconBookmark,
|
||||
IconShare,
|
||||
} from '@tabler/icons-vue';
|
||||
|
||||
interface SocialAccount {
|
||||
id: string;
|
||||
platform: string;
|
||||
|
|
@ -29,175 +18,138 @@ interface Props {
|
|||
socialAccount: SocialAccount;
|
||||
content: string;
|
||||
media: MediaItem[];
|
||||
charCount: number;
|
||||
maxLength: number;
|
||||
isValid: boolean;
|
||||
validationMessage: string;
|
||||
isUploading?: boolean;
|
||||
readonly?: boolean;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:content': [value: string];
|
||||
'upload': [event: Event];
|
||||
'remove-media': [mediaId: string];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bg-white dark:bg-[#191b22] rounded-xl overflow-hidden border border-gray-200 dark:border-[#313543]">
|
||||
<!-- Post Content -->
|
||||
<div class="p-4">
|
||||
<div class="flex gap-3">
|
||||
<!-- Avatar -->
|
||||
<div class="shrink-0">
|
||||
<img
|
||||
v-if="socialAccount.avatar_url"
|
||||
:src="socialAccount.avatar_url"
|
||||
:alt="socialAccount.display_name"
|
||||
class="h-11 w-11 rounded-lg object-cover"
|
||||
/>
|
||||
<div v-else class="h-11 w-11 rounded-lg bg-[#6364FF] flex items-center justify-center text-white font-bold">
|
||||
<div class="w-full h-full bg-white dark:bg-[#191b22] text-[#1f232b] dark:text-white overflow-hidden flex flex-col">
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="flex-1 overflow-y-auto mt-4">
|
||||
<!-- Post -->
|
||||
<div class="px-4 pb-3">
|
||||
<!-- Author -->
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<img v-if="socialAccount.avatar_url" :src="socialAccount.avatar_url"
|
||||
:alt="socialAccount.display_name" class="h-10 w-10 rounded-full object-cover" />
|
||||
<div v-else
|
||||
class="h-10 w-10 rounded-full bg-gradient-to-br from-[#6364ff] to-[#563acc] flex items-center justify-center text-white font-semibold">
|
||||
{{ socialAccount.display_name?.charAt(0) }}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-bold text-[15px]">
|
||||
{{ socialAccount.display_name }}
|
||||
</div>
|
||||
<div class="text-[14px] text-[#606984] dark:text-[#9baec8] truncate">
|
||||
@{{ socialAccount.username || 'user' }}@mastodon.social
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content Area -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex flex-col">
|
||||
<span class="font-semibold text-[15px] text-gray-900 dark:text-white leading-tight">
|
||||
{{ socialAccount.display_name }}
|
||||
</span>
|
||||
<span class="text-gray-500 text-sm">
|
||||
@{{ socialAccount.username }}
|
||||
</span>
|
||||
</div>
|
||||
<button class="p-1 hover:bg-gray-100 dark:hover:bg-[#282c37] rounded">
|
||||
<IconDots class="h-5 w-5 text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
<!-- Content -->
|
||||
<div v-if="content" class="text-[16px] whitespace-pre-wrap leading-[22px] mb-3">
|
||||
{{ content }}
|
||||
</div>
|
||||
|
||||
<!-- Post Content -->
|
||||
<div class="mt-2">
|
||||
<div v-if="props.readonly" class="w-full min-h-[60px] text-[15px] text-gray-900 dark:text-white whitespace-pre-wrap">
|
||||
{{ content || 'No content' }}
|
||||
</div>
|
||||
<textarea
|
||||
v-else
|
||||
:value="content"
|
||||
@input="emit('update:content', ($event.target as HTMLTextAreaElement).value)"
|
||||
class="w-full min-h-[60px] bg-transparent border-0 p-0 text-[15px] text-gray-900 dark:text-white resize-none focus:outline-none focus:ring-0 placeholder:text-gray-400"
|
||||
placeholder="What is on your mind?"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Media -->
|
||||
<div v-if="media.length > 0" class="mt-3">
|
||||
<div
|
||||
class="grid gap-1 rounded-lg overflow-hidden"
|
||||
:class="{
|
||||
'grid-cols-1': media.length === 1,
|
||||
'grid-cols-2': media.length === 2,
|
||||
'grid-cols-2 grid-rows-2': media.length >= 3,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
v-for="(item, index) in media.slice(0, 4)"
|
||||
:key="item.id"
|
||||
class="relative group overflow-hidden"
|
||||
:class="{
|
||||
'aspect-video': media.length === 1,
|
||||
'aspect-square': media.length > 1,
|
||||
'col-span-2': media.length === 3 && index === 0,
|
||||
}"
|
||||
>
|
||||
<img
|
||||
v-if="item.type === 'image'"
|
||||
:src="item.url"
|
||||
:alt="item.original_filename"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
<video
|
||||
v-else
|
||||
:src="item.url"
|
||||
class="w-full h-full object-cover bg-black"
|
||||
muted
|
||||
loop
|
||||
playsinline
|
||||
/>
|
||||
<button
|
||||
v-if="!props.readonly"
|
||||
type="button"
|
||||
@click="emit('remove-media', item.id)"
|
||||
class="absolute top-2 right-2 bg-black/70 text-white rounded-full p-1.5 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<IconX class="h-4 w-4" />
|
||||
</button>
|
||||
<div
|
||||
v-if="media.length > 4 && index === 3"
|
||||
class="absolute inset-0 bg-black/60 flex items-center justify-center"
|
||||
>
|
||||
<span class="text-white text-xl font-semibold">+{{ media.length - 4 }}</span>
|
||||
</div>
|
||||
<!-- Media -->
|
||||
<div v-if="media.length > 0" class="mb-3">
|
||||
<div class="rounded-lg overflow-hidden" :class="{
|
||||
'grid grid-cols-2 gap-0.5': media.length >= 2,
|
||||
}">
|
||||
<div v-for="(item, index) in media.slice(0, 4)" :key="item.id"
|
||||
class="relative overflow-hidden" :class="{
|
||||
'aspect-[4/3]': media.length === 1,
|
||||
'aspect-square': media.length > 1,
|
||||
}">
|
||||
<img v-if="item.type === 'image'" :src="item.url" :alt="item.original_filename"
|
||||
class="w-full h-full object-cover" />
|
||||
<video v-else :src="item.url" class="w-full h-full object-cover bg-black" muted loop
|
||||
playsinline />
|
||||
<!-- Hide button -->
|
||||
<button class="absolute top-2 right-2 bg-black/60 text-white text-[12px] px-2 py-0.5 rounded">
|
||||
Hide
|
||||
</button>
|
||||
<div v-if="media.length > 4 && index === 3"
|
||||
class="absolute inset-0 bg-black/60 flex items-center justify-center">
|
||||
<span class="text-white text-xl font-semibold">+{{ media.length - 4 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Timestamp -->
|
||||
<div class="mt-3 text-gray-500 text-sm">
|
||||
now
|
||||
</div>
|
||||
<!-- Timestamp -->
|
||||
<div class="text-[14px] text-[#606984] dark:text-[#9baec8] mb-2">
|
||||
<span>Jan 21, 2026, 04:30 PM</span>
|
||||
<span class="mx-1.5">·</span>
|
||||
<svg class="inline h-4 w-4 align-text-bottom" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="2" y1="12" x2="22" y2="12" />
|
||||
<path
|
||||
d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
|
||||
</svg>
|
||||
<span class="mx-1.5">·</span>
|
||||
<span>Web</span>
|
||||
</div>
|
||||
|
||||
<!-- Engagement Actions -->
|
||||
<div class="flex items-center justify-between mt-3 pt-3 border-t border-gray-200 dark:border-[#313543] -ml-2 -mr-2">
|
||||
<button class="flex items-center gap-1 px-3 py-1.5 hover:bg-[#6364FF]/10 rounded group text-gray-500">
|
||||
<IconMessageCircle class="h-5 w-5 group-hover:text-[#6364FF]" />
|
||||
<span class="text-sm group-hover:text-[#6364FF]">0</span>
|
||||
</button>
|
||||
<button class="flex items-center gap-1 px-3 py-1.5 hover:bg-green-500/10 rounded group text-gray-500">
|
||||
<IconRepeat class="h-5 w-5 group-hover:text-green-500" />
|
||||
<span class="text-sm group-hover:text-green-500">0</span>
|
||||
</button>
|
||||
<button class="flex items-center gap-1 px-3 py-1.5 hover:bg-yellow-500/10 rounded group text-gray-500">
|
||||
<IconHeart class="h-5 w-5 group-hover:text-yellow-500" />
|
||||
<span class="text-sm group-hover:text-yellow-500">0</span>
|
||||
</button>
|
||||
<button class="px-3 py-1.5 hover:bg-[#6364FF]/10 rounded group text-gray-500">
|
||||
<IconBookmark class="h-5 w-5 group-hover:text-[#6364FF]" />
|
||||
</button>
|
||||
<button class="px-3 py-1.5 hover:bg-[#6364FF]/10 rounded group text-gray-500">
|
||||
<IconShare class="h-5 w-5 group-hover:text-[#6364FF]" />
|
||||
</button>
|
||||
</div>
|
||||
<!-- Stats -->
|
||||
<div class="text-[14px] text-[#606984] dark:text-[#9baec8]">
|
||||
<span class="font-semibold text-[#1f232b] dark:text-white">0</span> boosts
|
||||
<span class="mx-1.5">·</span>
|
||||
<span class="font-semibold text-[#1f232b] dark:text-white">0</span> quotes
|
||||
<span class="mx-1.5">·</span>
|
||||
<span class="font-semibold text-[#1f232b] dark:text-white">0</span> favorites
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer with upload and char count (only in edit mode) -->
|
||||
<div v-if="!props.readonly" class="border-t border-gray-200 dark:border-[#313543] px-4 py-3 flex items-center justify-between bg-gray-50 dark:bg-[#1f2128]">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="text-xs px-2 py-1 rounded-full"
|
||||
:class="isValid ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' : 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'"
|
||||
>
|
||||
{{ validationMessage }}
|
||||
</span>
|
||||
<!-- Actions -->
|
||||
<div class="border-t border-[#c9d4de] dark:border-[#313543]">
|
||||
<div class="flex items-center justify-around py-2">
|
||||
<!-- Reply -->
|
||||
<button class="p-2 hover:bg-[#6364ff]/10 rounded-full">
|
||||
<svg class="h-5 w-5 text-[#606984] dark:text-[#9baec8]" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="1.5">
|
||||
<path d="M3 10h10a5 5 0 0 1 5 5v6M3 10l6 6M3 10l6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Boost -->
|
||||
<button class="p-2 hover:bg-[#8c8dff]/10 rounded-full">
|
||||
<svg class="h-5 w-5 text-[#606984] dark:text-[#9baec8]" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="1.5">
|
||||
<path d="M17 1l4 4-4 4" />
|
||||
<path d="M3 11V9a4 4 0 0 1 4-4h14" />
|
||||
<path d="M7 23l-4-4 4-4" />
|
||||
<path d="M21 13v2a4 4 0 0 1-4 4H3" />
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Favourite (Star) -->
|
||||
<button class="p-2 hover:bg-[#ca8f04]/10 rounded-full">
|
||||
<svg class="h-5 w-5 text-[#606984] dark:text-[#9baec8]" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="1.5">
|
||||
<polygon
|
||||
points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Bookmark -->
|
||||
<button class="p-2 hover:bg-[#6364ff]/10 rounded-full">
|
||||
<svg class="h-5 w-5 text-[#606984] dark:text-[#9baec8]" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="1.5">
|
||||
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
<!-- More -->
|
||||
<button class="p-2 hover:bg-gray-100 dark:hover:bg-[#313543] rounded-full">
|
||||
<svg class="h-5 w-5 text-[#606984] dark:text-[#9baec8]" viewBox="0 0 24 24" fill="currentColor">
|
||||
<circle cx="12" cy="12" r="1.5" />
|
||||
<circle cx="6" cy="12" r="1.5" />
|
||||
<circle cx="18" cy="12" r="1.5" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<label class="cursor-pointer p-2 rounded-full hover:bg-gray-200 dark:hover:bg-[#313543] transition-colors">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*,video/*"
|
||||
multiple
|
||||
class="hidden"
|
||||
@change="emit('upload', $event)"
|
||||
:disabled="isUploading"
|
||||
/>
|
||||
<IconPhoto class="h-5 w-5 text-gray-500" />
|
||||
</label>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
|
@ -1,20 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
import {
|
||||
IconX,
|
||||
IconPhoto,
|
||||
IconShare,
|
||||
IconUpload,
|
||||
} from '@tabler/icons-vue';
|
||||
import { IconPhoto, IconStack2 } from '@tabler/icons-vue';
|
||||
import { computed } from 'vue';
|
||||
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
|
||||
interface SocialAccount {
|
||||
id: string;
|
||||
platform: string;
|
||||
|
|
@ -30,282 +17,133 @@ interface MediaItem {
|
|||
original_filename: string;
|
||||
}
|
||||
|
||||
interface ContentTypeOption {
|
||||
value: string;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface PinterestBoard {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
socialAccount: SocialAccount;
|
||||
content: string;
|
||||
media: MediaItem[];
|
||||
contentType?: string;
|
||||
contentTypeOptions?: ContentTypeOption[];
|
||||
meta?: Record<string, any>;
|
||||
platformData?: {
|
||||
boards?: PinterestBoard[];
|
||||
};
|
||||
charCount: number;
|
||||
maxLength: number;
|
||||
isValid: boolean;
|
||||
validationMessage: string;
|
||||
isUploading?: boolean;
|
||||
readonly?: boolean;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:content': [value: string];
|
||||
'update:contentType': [value: string];
|
||||
'update:meta': [value: Record<string, any>];
|
||||
'upload': [event: Event];
|
||||
'remove-media': [mediaId: string];
|
||||
}>();
|
||||
|
||||
const isCarousel = computed(() => props.contentType === 'pinterest_carousel');
|
||||
const isVideoPin = computed(() => props.contentType === 'pinterest_video_pin');
|
||||
|
||||
const boards = computed(() => props.platformData?.boards || []);
|
||||
const hasMultipleContentTypes = computed(() => (props.contentTypeOptions?.length || 0) > 1);
|
||||
|
||||
const updateBoardId = (boardId: string) => {
|
||||
emit('update:meta', { ...props.meta, board_id: boardId });
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<!-- Settings Bar -->
|
||||
<div v-if="(hasMultipleContentTypes || boards.length > 0) && !props.readonly" class="flex items-center justify-center gap-4 flex-wrap">
|
||||
<!-- Content Type Selector -->
|
||||
<div v-if="hasMultipleContentTypes" class="flex items-center gap-2">
|
||||
<span class="text-sm text-muted-foreground">Type:</span>
|
||||
<Select
|
||||
:model-value="contentType"
|
||||
@update:model-value="emit('update:contentType', $event)"
|
||||
>
|
||||
<SelectTrigger class="w-[140px] h-8">
|
||||
<SelectValue placeholder="Select type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="option in contentTypeOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<!-- Board Selector -->
|
||||
<div v-if="boards.length > 0" class="flex items-center gap-2">
|
||||
<span class="text-sm text-muted-foreground">Board:</span>
|
||||
<Select
|
||||
:model-value="meta?.board_id || ''"
|
||||
@update:model-value="updateBoardId"
|
||||
>
|
||||
<SelectTrigger class="w-[160px] h-8">
|
||||
<SelectValue placeholder="Select board" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="board in boards"
|
||||
:key="board.id"
|
||||
:value="board.id"
|
||||
>
|
||||
{{ board.name }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<!-- No Boards Warning -->
|
||||
<div v-else class="text-sm text-amber-600 dark:text-amber-400">
|
||||
No boards found. Create a board on Pinterest first.
|
||||
</div>
|
||||
<div class="w-full h-full bg-white dark:bg-[#121212] text-[#111111] dark:text-white overflow-hidden flex flex-col">
|
||||
<!-- Pinterest Header -->
|
||||
<div class="flex-shrink-0 h-11 border-b border-[#cdcdcd] dark:border-[#262626] flex items-center justify-center px-3">
|
||||
<!-- Pinterest Logo -->
|
||||
<svg class="h-6 w-6 text-[#e60023]" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 0C5.373 0 0 5.373 0 12c0 5.084 3.163 9.426 7.627 11.174-.105-.949-.2-2.405.042-3.441.218-.937 1.407-5.965 1.407-5.965s-.359-.719-.359-1.782c0-1.668.967-2.914 2.171-2.914 1.023 0 1.518.769 1.518 1.69 0 1.029-.655 2.568-.994 3.995-.283 1.194.599 2.169 1.777 2.169 2.133 0 3.772-2.249 3.772-5.495 0-2.873-2.064-4.882-5.012-4.882-3.414 0-5.418 2.561-5.418 5.207 0 1.031.397 2.138.893 2.738.098.119.112.224.083.345l-.333 1.36c-.053.22-.174.267-.402.161-1.499-.698-2.436-2.889-2.436-4.649 0-3.785 2.75-7.262 7.929-7.262 4.163 0 7.398 2.967 7.398 6.931 0 4.136-2.607 7.464-6.227 7.464-1.216 0-2.359-.632-2.75-1.378l-.748 2.853c-.271 1.043-1.002 2.35-1.492 3.146C9.57 23.812 10.763 24 12 24c6.627 0 12-5.373 12-12S18.627 0 12 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Pinterest Card Preview -->
|
||||
<div class="bg-white dark:bg-[#1e1e1e] rounded-2xl overflow-hidden border border-gray-200 dark:border-gray-700 max-w-[350px] mx-auto shadow-sm">
|
||||
<!-- Pin Image/Video Area -->
|
||||
<div class="relative">
|
||||
<!-- Media Display -->
|
||||
<div v-if="media.length > 0" class="relative">
|
||||
<!-- Single Image or Video -->
|
||||
<div v-if="!isCarousel || media.length === 1" class="relative">
|
||||
<img
|
||||
v-if="media[0].type === 'image'"
|
||||
:src="media[0].url"
|
||||
:alt="media[0].original_filename"
|
||||
class="w-full aspect-[2/3] object-cover"
|
||||
/>
|
||||
<video
|
||||
v-else
|
||||
:src="media[0].url"
|
||||
class="w-full aspect-[2/3] object-cover bg-black"
|
||||
muted
|
||||
loop
|
||||
playsinline
|
||||
/>
|
||||
<!-- Remove button -->
|
||||
<button
|
||||
v-if="!props.readonly"
|
||||
type="button"
|
||||
@click="emit('remove-media', media[0].id)"
|
||||
class="absolute top-3 right-3 bg-black/60 text-white rounded-full p-1.5 opacity-0 hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<IconX class="h-4 w-4" />
|
||||
</button>
|
||||
<!-- Video indicator -->
|
||||
<div v-if="media[0].type === 'video'" class="absolute bottom-3 left-3 bg-black/60 text-white text-xs px-2 py-1 rounded-full flex items-center gap-1">
|
||||
<IconUpload class="h-3 w-3" />
|
||||
Video
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Carousel -->
|
||||
<div v-else class="relative">
|
||||
<div class="flex overflow-x-auto snap-x snap-mandatory scrollbar-hide">
|
||||
<div
|
||||
v-for="item in media"
|
||||
:key="item.id"
|
||||
class="relative flex-shrink-0 w-full snap-center"
|
||||
>
|
||||
<!-- Main Content - Pin Card -->
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<div class="p-3">
|
||||
<!-- Pin Card -->
|
||||
<div class="bg-white dark:bg-[#1e1e1e] rounded-2xl overflow-hidden shadow-[0_1px_8px_0_rgba(0,0,0,0.1)] group">
|
||||
<!-- Pin Image/Video Area -->
|
||||
<div class="relative">
|
||||
<!-- Media Display -->
|
||||
<div v-if="media.length > 0" class="relative">
|
||||
<!-- Single Image or Video -->
|
||||
<div v-if="!isCarousel || media.length === 1" class="relative">
|
||||
<img
|
||||
:src="item.url"
|
||||
:alt="item.original_filename"
|
||||
v-if="media[0].type === 'image'"
|
||||
:src="media[0].url"
|
||||
:alt="media[0].original_filename"
|
||||
class="w-full aspect-[2/3] object-cover"
|
||||
/>
|
||||
<button
|
||||
v-if="!props.readonly"
|
||||
type="button"
|
||||
@click="emit('remove-media', item.id)"
|
||||
class="absolute top-3 right-3 bg-black/60 text-white rounded-full p-1.5 opacity-0 hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<IconX class="h-4 w-4" />
|
||||
</button>
|
||||
<video
|
||||
v-else
|
||||
:src="media[0].url"
|
||||
class="w-full aspect-[2/3] object-cover bg-black"
|
||||
muted
|
||||
loop
|
||||
playsinline
|
||||
/>
|
||||
<!-- Video indicator -->
|
||||
<div v-if="media[0].type === 'video'" class="absolute bottom-2 left-2 bg-black/60 text-white text-[10px] px-2 py-0.5 rounded-full flex items-center gap-1">
|
||||
<svg class="h-2.5 w-2.5" viewBox="0 0 24 24" fill="currentColor">
|
||||
<polygon points="5,3 19,12 5,21"/>
|
||||
</svg>
|
||||
Video
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Carousel -->
|
||||
<div v-else class="relative">
|
||||
<div class="flex overflow-x-auto snap-x snap-mandatory scrollbar-hide">
|
||||
<div
|
||||
v-for="item in media"
|
||||
:key="item.id"
|
||||
class="relative shrink-0 w-full snap-center"
|
||||
>
|
||||
<img
|
||||
:src="item.url"
|
||||
:alt="item.original_filename"
|
||||
class="w-full aspect-[2/3] object-cover"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Carousel indicator -->
|
||||
<div class="absolute bottom-2 left-1/2 -translate-x-1/2 flex gap-1">
|
||||
<div
|
||||
v-for="(_, index) in media"
|
||||
:key="index"
|
||||
class="w-1.5 h-1.5 rounded-full"
|
||||
:class="index === 0 ? 'bg-white' : 'bg-white/50'"
|
||||
/>
|
||||
</div>
|
||||
<!-- Carousel count badge -->
|
||||
<div class="absolute top-2 left-2 bg-black/60 text-white text-[10px] px-2 py-0.5 rounded-full flex items-center gap-1">
|
||||
<IconStack2 class="h-2.5 w-2.5" />
|
||||
{{ media.length }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pin Hover Actions -->
|
||||
<div class="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors pointer-events-none">
|
||||
<div class="absolute top-2 right-2 flex gap-1.5 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-auto">
|
||||
<button class="bg-[#e60023] hover:bg-[#ad081b] text-white rounded-full px-3 py-1.5 font-semibold text-[11px] transition-colors">
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Carousel indicator -->
|
||||
<div class="absolute bottom-3 left-1/2 -translate-x-1/2 flex gap-1.5">
|
||||
<div
|
||||
v-for="(_, index) in media"
|
||||
:key="index"
|
||||
class="w-1.5 h-1.5 rounded-full"
|
||||
:class="index === 0 ? 'bg-white' : 'bg-white/50'"
|
||||
|
||||
<!-- Empty State - No Media -->
|
||||
<div v-else class="aspect-[2/3] bg-[#efefef] dark:bg-[#2a2a2a] flex items-center justify-center">
|
||||
<IconPhoto class="h-8 w-8 text-[#cdcdcd] dark:text-[#555]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pin Info -->
|
||||
<div class="p-2.5">
|
||||
<!-- Description -->
|
||||
<div v-if="content" class="text-[13px] text-[#111111] dark:text-[#e0e0e0] line-clamp-2 leading-[17px]">
|
||||
{{ content }}
|
||||
</div>
|
||||
|
||||
<!-- User Info -->
|
||||
<div class="flex items-center gap-2 mt-2">
|
||||
<img
|
||||
v-if="socialAccount.avatar_url"
|
||||
:src="socialAccount.avatar_url"
|
||||
:alt="socialAccount.display_name"
|
||||
class="h-6 w-6 rounded-full object-cover"
|
||||
/>
|
||||
<div v-else class="h-6 w-6 rounded-full bg-[#e60023] flex items-center justify-center text-white font-semibold text-[10px]">
|
||||
{{ socialAccount.display_name?.charAt(0) }}
|
||||
</div>
|
||||
<span class="text-[12px] font-medium text-[#111111] dark:text-[#e0e0e0] truncate">
|
||||
{{ socialAccount.display_name }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pin Actions Overlay -->
|
||||
<div class="absolute top-3 left-3 right-3 flex justify-between opacity-0 hover:opacity-100 transition-opacity">
|
||||
<button class="bg-black/60 text-white rounded-full p-2">
|
||||
<IconShare class="h-4 w-4" />
|
||||
</button>
|
||||
<button class="bg-[#e60023] text-white rounded-full px-4 py-2 font-semibold text-sm hover:bg-[#ad081b] transition-colors">
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State - No Media -->
|
||||
<div v-else-if="!props.readonly" class="aspect-[2/3] bg-gray-100 dark:bg-gray-800 flex flex-col items-center justify-center">
|
||||
<label class="cursor-pointer flex flex-col items-center gap-3 p-6 text-center">
|
||||
<div class="p-4 bg-gray-200 dark:bg-gray-700 rounded-full">
|
||||
<IconPhoto class="h-8 w-8 text-gray-500 dark:text-gray-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ isVideoPin ? 'Add a video' : 'Add an image' }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
{{ isCarousel ? '2-5 images for carousel' : 'Recommended: 2:3 aspect ratio' }}
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
:accept="isVideoPin ? 'video/*' : 'image/*'"
|
||||
:multiple="isCarousel"
|
||||
class="hidden"
|
||||
@change="emit('upload', $event)"
|
||||
:disabled="isUploading"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div v-else class="aspect-[2/3] bg-gray-100 dark:bg-gray-800 flex flex-col items-center justify-center">
|
||||
<div class="p-4 bg-gray-200 dark:bg-gray-700 rounded-full">
|
||||
<IconPhoto class="h-8 w-8 text-gray-500 dark:text-gray-400" />
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 mt-3">No media</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pin Content -->
|
||||
<div class="p-4">
|
||||
<!-- Description -->
|
||||
<div v-if="props.readonly" class="w-full min-h-[60px] text-sm text-gray-900 dark:text-white whitespace-pre-wrap">
|
||||
{{ content || 'No content' }}
|
||||
</div>
|
||||
<textarea
|
||||
v-else
|
||||
:value="content"
|
||||
@input="emit('update:content', ($event.target as HTMLTextAreaElement).value)"
|
||||
class="w-full min-h-[60px] bg-transparent border-0 p-0 text-sm text-gray-900 dark:text-white resize-none focus:outline-none focus:ring-0 placeholder:text-gray-400"
|
||||
placeholder="Add a description..."
|
||||
/>
|
||||
|
||||
<!-- User Info -->
|
||||
<div class="flex items-center gap-2 mt-3 pt-3 border-t border-gray-100 dark:border-gray-700">
|
||||
<img
|
||||
v-if="socialAccount.avatar_url"
|
||||
:src="socialAccount.avatar_url"
|
||||
:alt="socialAccount.display_name"
|
||||
class="h-8 w-8 rounded-full object-cover"
|
||||
/>
|
||||
<div v-else class="h-8 w-8 rounded-full bg-[#e60023] flex items-center justify-center text-white font-bold text-sm">
|
||||
{{ socialAccount.display_name?.charAt(0) }}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-semibold text-gray-900 dark:text-white truncate">
|
||||
{{ socialAccount.display_name }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500">
|
||||
{{ socialAccount.username ? `@${socialAccount.username}` : 'Pinterest' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer with upload and char count (only in edit mode) -->
|
||||
<div v-if="!props.readonly" class="border-t border-gray-200 dark:border-gray-700 px-4 py-3 flex items-center justify-between bg-gray-50 dark:bg-gray-800/50">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="text-xs px-2 py-1 rounded-full"
|
||||
:class="isValid ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' : 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'"
|
||||
>
|
||||
{{ validationMessage }}
|
||||
</span>
|
||||
</div>
|
||||
<label v-if="media.length > 0 && (isCarousel && media.length < 5)" class="cursor-pointer p-2 rounded-full hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
class="hidden"
|
||||
@change="emit('upload', $event)"
|
||||
:disabled="isUploading"
|
||||
/>
|
||||
<IconPhoto class="h-5 w-5 text-gray-500" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -27,39 +27,16 @@ interface MediaItem {
|
|||
original_filename: string;
|
||||
}
|
||||
|
||||
interface ContentTypeOption {
|
||||
value: string;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
platform: string;
|
||||
socialAccount: SocialAccount;
|
||||
content: string;
|
||||
media: MediaItem[];
|
||||
contentType?: string;
|
||||
contentTypeOptions?: ContentTypeOption[];
|
||||
meta?: Record<string, any>;
|
||||
platformData?: Record<string, any>;
|
||||
charCount: number;
|
||||
maxLength: number;
|
||||
isValid: boolean;
|
||||
validationMessage: string;
|
||||
isUploading?: boolean;
|
||||
readonly?: boolean;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:content': [value: string];
|
||||
'update:contentType': [value: string];
|
||||
'update:meta': [value: Record<string, any>];
|
||||
'upload': [event: Event];
|
||||
'remove-media': [mediaId: string];
|
||||
}>();
|
||||
|
||||
const previewComponent = computed(() => {
|
||||
switch (props.platform) {
|
||||
case 'linkedin':
|
||||
|
|
@ -87,26 +64,6 @@ const previewComponent = computed(() => {
|
|||
return LinkedInPreview;
|
||||
}
|
||||
});
|
||||
|
||||
const handleContentUpdate = (value: string) => {
|
||||
emit('update:content', value);
|
||||
};
|
||||
|
||||
const handleContentTypeUpdate = (value: string) => {
|
||||
emit('update:contentType', value);
|
||||
};
|
||||
|
||||
const handleMetaUpdate = (value: Record<string, any>) => {
|
||||
emit('update:meta', value);
|
||||
};
|
||||
|
||||
const handleUpload = (event: Event) => {
|
||||
emit('upload', event);
|
||||
};
|
||||
|
||||
const handleRemoveMedia = (mediaId: string) => {
|
||||
emit('remove-media', mediaId);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -116,19 +73,5 @@ const handleRemoveMedia = (mediaId: string) => {
|
|||
:content="content"
|
||||
:media="media"
|
||||
:content-type="contentType"
|
||||
:content-type-options="contentTypeOptions"
|
||||
:meta="meta"
|
||||
:platform-data="platformData"
|
||||
:char-count="charCount"
|
||||
:max-length="maxLength"
|
||||
:is-valid="isValid"
|
||||
:validation-message="validationMessage"
|
||||
:is-uploading="isUploading"
|
||||
:readonly="readonly"
|
||||
@update:content="handleContentUpdate"
|
||||
@update:content-type="handleContentTypeUpdate"
|
||||
@update:meta="handleMetaUpdate"
|
||||
@upload="handleUpload"
|
||||
@remove-media="handleRemoveMedia"
|
||||
/>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,13 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import {
|
||||
IconDots,
|
||||
IconX,
|
||||
IconPhoto,
|
||||
IconHeart,
|
||||
IconMessageCircle,
|
||||
IconRepeat,
|
||||
IconSend,
|
||||
} from '@tabler/icons-vue';
|
||||
import { computed } from 'vue';
|
||||
|
||||
interface SocialAccount {
|
||||
id: string;
|
||||
|
|
@ -28,169 +20,149 @@ interface Props {
|
|||
socialAccount: SocialAccount;
|
||||
content: string;
|
||||
media: MediaItem[];
|
||||
charCount: number;
|
||||
maxLength: number;
|
||||
isValid: boolean;
|
||||
validationMessage: string;
|
||||
isUploading?: boolean;
|
||||
readonly?: boolean;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:content': [value: string];
|
||||
'upload': [event: Event];
|
||||
'remove-media': [mediaId: string];
|
||||
}>();
|
||||
const username = computed(() => props.socialAccount.username || props.socialAccount.display_name);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bg-white dark:bg-[#101010] rounded-xl overflow-hidden border border-gray-200 dark:border-gray-800">
|
||||
<!-- Thread Content -->
|
||||
<div class="p-4">
|
||||
<div class="flex gap-3">
|
||||
<!-- Avatar and thread line -->
|
||||
<div class="flex flex-col items-center">
|
||||
<img
|
||||
v-if="socialAccount.avatar_url"
|
||||
:src="socialAccount.avatar_url"
|
||||
:alt="socialAccount.display_name"
|
||||
class="h-9 w-9 rounded-full object-cover"
|
||||
/>
|
||||
<div v-else class="h-9 w-9 rounded-full bg-gradient-to-br from-gray-700 to-gray-900 flex items-center justify-center text-white font-bold text-sm">
|
||||
{{ socialAccount.display_name?.charAt(0) }}
|
||||
<div
|
||||
class="w-full h-full bg-white dark:bg-[#101010] text-[#000000] dark:text-[#f5f5f5] overflow-hidden flex flex-col">
|
||||
<!-- Mobile Header -->
|
||||
<div
|
||||
class="flex-shrink-0 h-11 flex items-center justify-between px-4 border-b border-[#e0e0e0] dark:border-[#262626]">
|
||||
<!-- Back arrow -->
|
||||
<button class="p-1 -ml-1">
|
||||
<svg class="h-6 w-6 text-[#000000] dark:text-[#f5f5f5]" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2">
|
||||
<path d="M19 12H5M12 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Threads Logo -->
|
||||
<svg class="w-7 h-7" viewBox="0 0 192 192" fill="currentColor">
|
||||
<path
|
||||
d="M141.537 88.9883C140.71 88.5919 139.87 88.2104 139.019 87.8451C137.537 60.5382 122.616 44.905 97.5619 44.745C97.4484 44.7443 97.3349 44.7443 97.2214 44.7443C82.2635 44.7443 69.7598 50.9181 62.0019 62.7124L77.3095 72.8084C83.1212 63.9548 91.7464 62.0206 97.2728 62.0206C97.3508 62.0206 97.4289 62.0206 97.5063 62.0213C106.347 62.0785 112.987 64.4804 117.321 69.1467C120.495 72.5682 122.635 77.4129 123.711 83.6087C116.923 82.3915 109.583 81.8491 101.772 81.9949C79.8064 82.3799 65.3203 94.1014 66.2476 111.253C66.7191 119.978 70.8908 127.556 78.0361 132.581C84.0521 136.829 91.5785 138.981 99.4574 138.752C110.047 138.449 118.558 134.531 124.713 127.122C129.313 121.588 132.401 114.47 133.963 105.889C139.917 109.378 144.254 113.94 146.599 119.461C150.463 128.405 150.675 143.054 138.575 155.153C128.01 165.719 115.048 170.176 96.2496 170.343C75.1149 170.156 59.0699 163.64 48.4969 151.014C38.4904 139.058 33.2931 121.85 33.0691 100.054C33.2931 78.2575 38.4904 61.0498 48.4969 49.0929C59.0699 36.4672 75.1149 29.9506 96.2496 29.7631C117.502 29.9513 133.728 36.5004 144.542 49.0964C149.82 55.2421 153.77 62.7749 156.325 71.4739L172.775 67.0265C169.456 56.0724 164.188 46.5832 157.081 38.6884C143.36 22.8619 123.353 14.6343 96.3549 14.4314C96.3198 14.4313 96.2847 14.4313 96.2496 14.4313C96.2145 14.4313 96.1794 14.4313 96.1443 14.4314C69.1466 14.6343 49.1398 22.8619 35.4188 38.6884C22.4674 53.6261 15.8782 74.3068 15.6523 100.007L15.6523 100.054L15.6523 100.1C15.8782 125.801 22.4674 146.481 35.4188 161.419C49.1398 177.245 69.1466 185.473 96.1443 185.676C96.1794 185.676 96.2145 185.676 96.2496 185.676C96.2847 185.676 96.3198 185.676 96.3549 185.676C119.718 185.446 137.054 179.474 150.393 166.134C167.788 148.739 167.297 126.992 161.539 113.128C157.178 102.609 148.783 94.0696 137.012 88.0016C137.032 87.9922 137.051 87.9828 137.071 87.9734C137.071 87.9734 137.071 87.9734 137.071 87.9734ZM99.0146 121.609C88.7879 121.863 82.5113 116.366 82.1765 109.508C81.7961 101.655 89.3498 96.2856 101.943 96.0483C106.946 95.9536 111.679 96.3391 116.093 97.1042C115.07 115.486 107.863 121.392 99.0146 121.609Z" />
|
||||
</svg>
|
||||
<!-- Menu -->
|
||||
<button class="p-1 -mr-1">
|
||||
<svg class="h-6 w-6 text-[#000000] dark:text-[#f5f5f5]" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2">
|
||||
<line x1="3" y1="6" x2="21" y2="6" />
|
||||
<line x1="3" y1="12" x2="21" y2="12" />
|
||||
<line x1="3" y1="18" x2="21" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<!-- Post -->
|
||||
<div class="px-4 pt-3 pb-2">
|
||||
<!-- Author row -->
|
||||
<div class="flex items-center gap-2.5">
|
||||
<!-- Avatar -->
|
||||
<div class="w-9 h-9 rounded-full overflow-hidden flex-shrink-0">
|
||||
<img v-if="socialAccount.avatar_url" :src="socialAccount.avatar_url"
|
||||
:alt="socialAccount.display_name" class="w-full h-full object-cover" />
|
||||
<div v-else
|
||||
class="w-full h-full bg-gradient-to-br from-[#833ab4] via-[#fd1d1d] to-[#fcb045] flex items-center justify-center text-white font-bold text-sm">
|
||||
{{ socialAccount.display_name?.charAt(0).toUpperCase() }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Name + verified -->
|
||||
<div class="flex items-center gap-1 min-w-0">
|
||||
<span class="font-semibold text-[15px] text-[#000000] dark:text-[#f5f5f5] truncate">
|
||||
{{ username }}
|
||||
</span>
|
||||
<!-- Verified badge -->
|
||||
<svg class="h-3.5 w-3.5 text-[#0095f6] flex-shrink-0" viewBox="0 0 40 40" fill="currentColor">
|
||||
<path
|
||||
d="M19.998 3.094L14.638 0l-2.972 5.15H5.432v6.354L0 14.64 3.094 20 0 25.359l5.432 3.137v5.905h5.975L14.638 40l5.36-3.094L25.358 40l3.232-5.6h6.162v-6.01L40 25.359 36.905 20 40 14.641l-5.248-3.03v-6.46h-6.419L25.358 0l-5.36 3.094zm7.415 11.225l2.254 2.287-11.43 11.5-6.835-6.93 2.244-2.258 4.587 4.581 9.18-9.18z" />
|
||||
</svg>
|
||||
</div>
|
||||
<!-- Thread line -->
|
||||
<div class="w-0.5 flex-1 bg-gray-200 dark:bg-gray-800 mt-2 min-h-[20px]" />
|
||||
</div>
|
||||
|
||||
<!-- Content Area -->
|
||||
<div class="flex-1 min-w-0 pb-3">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="font-semibold text-[15px] text-gray-900 dark:text-white">
|
||||
{{ socialAccount.username || socialAccount.display_name }}
|
||||
</span>
|
||||
<span class="text-gray-500 text-sm">· now</span>
|
||||
</div>
|
||||
<button class="p-1 hover:bg-gray-100 dark:hover:bg-gray-900 rounded-full">
|
||||
<IconDots class="h-5 w-5 text-gray-500" />
|
||||
</button>
|
||||
</div>
|
||||
<!-- Content -->
|
||||
<div v-if="content" class="mt-3 text-[15px] text-[#000000] dark:text-[#f5f5f5] whitespace-pre-wrap leading-[22px]">
|
||||
{{ content }}
|
||||
</div>
|
||||
|
||||
<!-- Thread Content -->
|
||||
<div class="mt-0.5">
|
||||
<div v-if="props.readonly" class="w-full min-h-[60px] text-[15px] text-gray-900 dark:text-white whitespace-pre-wrap">
|
||||
{{ content || 'No content' }}
|
||||
</div>
|
||||
<textarea
|
||||
v-else
|
||||
:value="content"
|
||||
@input="emit('update:content', ($event.target as HTMLTextAreaElement).value)"
|
||||
class="w-full min-h-[60px] bg-transparent border-0 p-0 text-[15px] text-gray-900 dark:text-white resize-none focus:outline-none focus:ring-0 placeholder:text-gray-500"
|
||||
placeholder="Start a thread..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Media -->
|
||||
<div v-if="media.length > 0" class="mt-2">
|
||||
<div
|
||||
class="grid gap-1 rounded-xl overflow-hidden"
|
||||
:class="{
|
||||
'grid-cols-1': media.length === 1,
|
||||
'grid-cols-2': media.length >= 2,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
v-for="(item, index) in media.slice(0, 4)"
|
||||
:key="item.id"
|
||||
class="relative group overflow-hidden"
|
||||
:class="{
|
||||
'aspect-video': media.length === 1,
|
||||
'aspect-square': media.length > 1,
|
||||
}"
|
||||
>
|
||||
<img
|
||||
v-if="item.type === 'image'"
|
||||
:src="item.url"
|
||||
:alt="item.original_filename"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
<video
|
||||
v-else
|
||||
:src="item.url"
|
||||
class="w-full h-full object-cover bg-black"
|
||||
muted
|
||||
loop
|
||||
playsinline
|
||||
/>
|
||||
<button
|
||||
v-if="!props.readonly"
|
||||
type="button"
|
||||
@click="emit('remove-media', item.id)"
|
||||
class="absolute top-2 right-2 bg-black/70 text-white rounded-full p-1.5 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<IconX class="h-4 w-4" />
|
||||
</button>
|
||||
<div
|
||||
v-if="media.length > 4 && index === 3"
|
||||
class="absolute inset-0 bg-black/60 flex items-center justify-center"
|
||||
>
|
||||
<span class="text-white text-xl font-semibold">+{{ media.length - 4 }}</span>
|
||||
</div>
|
||||
<!-- Media -->
|
||||
<div v-if="media.length > 0" class="mt-3">
|
||||
<div class="rounded-xl overflow-hidden" :class="{
|
||||
'grid grid-cols-2 gap-0.5': media.length >= 2,
|
||||
}">
|
||||
<div v-for="(item, index) in media.slice(0, 4)" :key="item.id"
|
||||
class="relative overflow-hidden" :class="{
|
||||
'aspect-[4/3]': media.length === 1,
|
||||
'aspect-square': media.length > 1,
|
||||
}">
|
||||
<img v-if="item.type === 'image'" :src="item.url" :alt="item.original_filename"
|
||||
class="w-full h-full object-cover" />
|
||||
<video v-else :src="item.url" class="w-full h-full object-cover bg-black" muted loop
|
||||
playsinline />
|
||||
<div v-if="media.length > 4 && index === 3"
|
||||
class="absolute inset-0 bg-black/60 flex items-center justify-center">
|
||||
<span class="text-white text-xl font-semibold">+{{ media.length - 4 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Engagement Actions -->
|
||||
<div class="flex items-center gap-1 mt-3 -ml-2">
|
||||
<button class="p-2 hover:bg-gray-100 dark:hover:bg-gray-900 rounded-full group">
|
||||
<IconHeart class="h-5 w-5 text-gray-600 dark:text-gray-400 group-hover:text-red-500" />
|
||||
</button>
|
||||
<button class="p-2 hover:bg-gray-100 dark:hover:bg-gray-900 rounded-full group">
|
||||
<IconMessageCircle class="h-5 w-5 text-gray-600 dark:text-gray-400 group-hover:text-blue-500" />
|
||||
</button>
|
||||
<button class="p-2 hover:bg-gray-100 dark:hover:bg-gray-900 rounded-full group">
|
||||
<IconRepeat class="h-5 w-5 text-gray-600 dark:text-gray-400 group-hover:text-green-500" />
|
||||
</button>
|
||||
<button class="p-2 hover:bg-gray-100 dark:hover:bg-gray-900 rounded-full group">
|
||||
<IconSend class="h-5 w-5 text-gray-600 dark:text-gray-400 group-hover:text-blue-500" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="flex items-center gap-2 mt-1 text-sm text-gray-500">
|
||||
<span>12 replies</span>
|
||||
<span>·</span>
|
||||
<span>89 likes</span>
|
||||
</div>
|
||||
<!-- Location (after media) -->
|
||||
<div v-if="media.length > 0" class="mt-2 flex items-center gap-1">
|
||||
<svg class="h-4 w-4 text-[#999999]" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2">
|
||||
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z" />
|
||||
<circle cx="12" cy="10" r="3" />
|
||||
</svg>
|
||||
<span class="text-xs text-[#999999]">Around The World</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer with upload and char count (only in edit mode) -->
|
||||
<div v-if="!props.readonly" class="border-t border-gray-200 dark:border-gray-800 px-4 py-3 flex items-center justify-between bg-gray-50 dark:bg-gray-900/50">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="text-xs px-2 py-1 rounded-full"
|
||||
:class="isValid ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' : 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'"
|
||||
>
|
||||
{{ validationMessage }}
|
||||
</span>
|
||||
<!-- Actions -->
|
||||
<div class="border-t border-[#e0e0e0] dark:border-[#262626]">
|
||||
<div class="flex items-center justify-around py-2">
|
||||
<!-- Like -->
|
||||
<button class="p-2">
|
||||
<svg class="h-6 w-6 text-[#000000] dark:text-[#f5f5f5]" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="1.5">
|
||||
<path
|
||||
d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Comment -->
|
||||
<button class="p-2">
|
||||
<svg class="h-6 w-6 text-[#000000] dark:text-[#f5f5f5]" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="1.5">
|
||||
<path
|
||||
d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" />
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Repost -->
|
||||
<button class="p-2">
|
||||
<svg class="h-6 w-6 text-[#000000] dark:text-[#f5f5f5]" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="1.5">
|
||||
<path d="M17 1l4 4-4 4" />
|
||||
<path d="M3 11V9a4 4 0 0 1 4-4h14" />
|
||||
<path d="M7 23l-4-4 4-4" />
|
||||
<path d="M21 13v2a4 4 0 0 1-4 4H3" />
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Share -->
|
||||
<button class="p-2">
|
||||
<svg class="h-6 w-6 text-[#000000] dark:text-[#f5f5f5]" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="1.5">
|
||||
<line x1="22" y1="2" x2="11" y2="13" />
|
||||
<polygon points="22 2 15 22 11 13 2 9 22 2" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<label class="cursor-pointer p-2 rounded-full hover:bg-gray-200 dark:hover:bg-gray-800 transition-colors">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*,video/*"
|
||||
multiple
|
||||
class="hidden"
|
||||
@change="emit('upload', $event)"
|
||||
:disabled="isUploading"
|
||||
/>
|
||||
<IconPhoto class="h-5 w-5 text-gray-500" />
|
||||
</label>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
|
@ -1,15 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import {
|
||||
IconVideo,
|
||||
IconX,
|
||||
IconPhoto,
|
||||
IconHeart,
|
||||
IconMessageCircle,
|
||||
IconBookmark,
|
||||
IconShare,
|
||||
IconMusic,
|
||||
IconPlus,
|
||||
} from '@tabler/icons-vue';
|
||||
import { IconPlus } from '@tabler/icons-vue';
|
||||
import { computed } from 'vue';
|
||||
|
||||
interface SocialAccount {
|
||||
id: string;
|
||||
|
|
@ -30,172 +21,235 @@ interface Props {
|
|||
socialAccount: SocialAccount;
|
||||
content: string;
|
||||
media: MediaItem[];
|
||||
charCount: number;
|
||||
maxLength: number;
|
||||
isValid: boolean;
|
||||
validationMessage: string;
|
||||
isUploading?: boolean;
|
||||
readonly?: boolean;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:content': [value: string];
|
||||
'upload': [event: Event];
|
||||
'remove-media': [mediaId: string];
|
||||
}>();
|
||||
// Format engagement numbers like TikTok does
|
||||
const formatNumber = (num: number): string => {
|
||||
if (num >= 1000000) {
|
||||
return (num / 1000000).toFixed(1).replace(/\.0$/, '') + 'M';
|
||||
}
|
||||
if (num >= 1000) {
|
||||
return (num / 1000).toFixed(1).replace(/\.0$/, '') + 'K';
|
||||
}
|
||||
return num.toString();
|
||||
};
|
||||
|
||||
const username = computed(() => props.socialAccount.username || props.socialAccount.display_name);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bg-black rounded-xl overflow-hidden relative" style="aspect-ratio: 9/16; max-height: 600px;">
|
||||
<!-- Video Area -->
|
||||
<div class="w-full h-full bg-black text-white overflow-hidden flex flex-col relative">
|
||||
<!-- Video/Media Area - Full screen -->
|
||||
<div class="absolute inset-0">
|
||||
<!-- Video content -->
|
||||
<div v-if="media.length > 0 && media[0].type === 'video'" class="w-full h-full">
|
||||
<video :src="media[0].url" class="w-full h-full object-cover" muted loop playsinline />
|
||||
<button
|
||||
v-if="!props.readonly"
|
||||
type="button"
|
||||
@click="emit('remove-media', media[0].id)"
|
||||
class="absolute top-4 right-4 bg-black/50 text-white rounded-full p-2"
|
||||
>
|
||||
<IconX class="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div v-else-if="media.length > 0 && media[0].type === 'image'" class="w-full h-full">
|
||||
<img :src="media[0].url" :alt="media[0].original_filename" class="w-full h-full object-cover" />
|
||||
<button
|
||||
v-if="!props.readonly"
|
||||
type="button"
|
||||
@click="emit('remove-media', media[0].id)"
|
||||
class="absolute top-4 right-4 bg-black/50 text-white rounded-full p-2"
|
||||
>
|
||||
<IconX class="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div v-else-if="!props.readonly" class="w-full h-full flex flex-col items-center justify-center bg-gray-900 text-gray-400">
|
||||
<IconVideo class="h-20 w-20 mb-4" />
|
||||
<p class="text-sm mb-2">Add a video</p>
|
||||
<label class="cursor-pointer bg-[#fe2c55] text-white px-6 py-2 rounded-full font-semibold text-sm hover:bg-[#e91e4f] transition-colors">
|
||||
<input
|
||||
type="file"
|
||||
accept="video/*,image/*"
|
||||
class="hidden"
|
||||
@change="emit('upload', $event)"
|
||||
:disabled="isUploading"
|
||||
/>
|
||||
Upload
|
||||
</label>
|
||||
</div>
|
||||
<div v-else class="w-full h-full flex flex-col items-center justify-center bg-gray-900 text-gray-400">
|
||||
<IconVideo class="h-20 w-20 mb-4" />
|
||||
<p class="text-sm">No media</p>
|
||||
<div v-else class="w-full h-full flex items-center justify-center bg-[#161823]">
|
||||
<svg class="h-12 w-12 text-white/20" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path
|
||||
d="M19.59 6.69a4.83 4.83 0 0 1-3.77-4.25V2h-3.45v13.67a2.89 2.89 0 0 1-5.2 1.74 2.89 2.89 0 0 1 2.31-4.64 2.93 2.93 0 0 1 .88.13V9.4a6.84 6.84 0 0 0-1-.05A6.33 6.33 0 0 0 5 20.1a6.34 6.34 0 0 0 10.86-4.43v-7a8.16 8.16 0 0 0 4.77 1.52v-3.4a4.85 4.85 0 0 1-1-.1z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Top Header (only when media exists) -->
|
||||
<div v-if="media.length > 0"
|
||||
class="absolute top-0 left-0 right-0 px-3 pt-1 pb-2 flex items-center justify-between z-10">
|
||||
<!-- Live button -->
|
||||
<button class="flex items-center gap-1 bg-white/10 backdrop-blur-sm rounded-full px-2.5 py-1">
|
||||
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path
|
||||
d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49m11.31-2.82a10 10 0 0 1 0 14.14m-14.14 0a10 10 0 0 1 0-14.14" />
|
||||
</svg>
|
||||
<span class="text-[11px] font-semibold">Live</span>
|
||||
</button>
|
||||
|
||||
<!-- Following / For You tabs -->
|
||||
<div class="flex items-center gap-4">
|
||||
<button class="text-white/60 text-[15px] font-medium">Following</button>
|
||||
<button class="text-white text-[15px] font-bold relative">
|
||||
For You
|
||||
<div class="absolute -bottom-1 left-1/2 -translate-x-1/2 w-6 h-[2px] bg-white rounded-full"></div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Search icon -->
|
||||
<button class="p-1">
|
||||
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path d="m21 21-4.35-4.35" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Gradient overlay at bottom -->
|
||||
<div class="absolute inset-x-0 bottom-0 h-1/2 bg-gradient-to-t from-black/80 via-black/40 to-transparent pointer-events-none" />
|
||||
<div v-if="media.length > 0"
|
||||
class="absolute inset-x-0 bottom-0 h-48 bg-gradient-to-t from-black/60 via-black/30 to-transparent pointer-events-none z-[5]" />
|
||||
|
||||
<!-- Right side actions -->
|
||||
<div class="absolute right-3 bottom-24 flex flex-col items-center gap-4">
|
||||
<!-- Profile -->
|
||||
<div class="relative mb-2">
|
||||
<img
|
||||
v-if="socialAccount.avatar_url"
|
||||
:src="socialAccount.avatar_url"
|
||||
:alt="socialAccount.display_name"
|
||||
class="h-12 w-12 rounded-full object-cover border-2 border-white"
|
||||
/>
|
||||
<div v-else class="h-12 w-12 rounded-full bg-gray-700 flex items-center justify-center text-white font-bold border-2 border-white">
|
||||
{{ socialAccount.display_name?.charAt(0) }}
|
||||
<div v-if="media.length > 0" class="absolute right-2 bottom-[72px] flex flex-col items-center gap-3.5 z-10">
|
||||
<!-- Profile with follow button -->
|
||||
<div class="relative mb-1">
|
||||
<div class="w-11 h-11 rounded-full border-[1.5px] border-white overflow-hidden">
|
||||
<img v-if="socialAccount.avatar_url" :src="socialAccount.avatar_url"
|
||||
:alt="socialAccount.display_name" class="w-full h-full object-cover" />
|
||||
<div v-else
|
||||
class="w-full h-full bg-[#2f2f2f] flex items-center justify-center text-white font-bold text-sm">
|
||||
{{ socialAccount.display_name?.charAt(0).toUpperCase() }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute -bottom-1.5 left-1/2 -translate-x-1/2 h-5 w-5 bg-[#fe2c55] rounded-full flex items-center justify-center">
|
||||
<IconPlus class="h-3 w-3 text-white" />
|
||||
<div
|
||||
class="absolute -bottom-1.5 left-1/2 -translate-x-1/2 w-5 h-5 bg-[#fe2c55] rounded-full flex items-center justify-center shadow-lg">
|
||||
<IconPlus class="h-3 w-3 text-white" stroke-width="3" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Like -->
|
||||
<button class="flex flex-col items-center">
|
||||
<div class="h-10 w-10 flex items-center justify-center">
|
||||
<IconHeart class="h-7 w-7 text-white" />
|
||||
<div class="w-10 h-10 flex items-center justify-center">
|
||||
<svg class="w-7 h-7 text-white drop-shadow-md" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path
|
||||
d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-white text-xs font-semibold">1.2M</span>
|
||||
<span class="text-white text-[11px] font-medium drop-shadow">{{ formatNumber(1200000) }}</span>
|
||||
</button>
|
||||
|
||||
<!-- Comment -->
|
||||
<button class="flex flex-col items-center">
|
||||
<div class="h-10 w-10 flex items-center justify-center">
|
||||
<IconMessageCircle class="h-7 w-7 text-white" />
|
||||
<div class="w-10 h-10 flex items-center justify-center">
|
||||
<svg class="w-7 h-7 text-white drop-shadow-md" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-white text-xs font-semibold">8.5K</span>
|
||||
<span class="text-white text-[11px] font-medium drop-shadow">{{ formatNumber(8523) }}</span>
|
||||
</button>
|
||||
|
||||
<!-- Bookmark -->
|
||||
<button class="flex flex-col items-center">
|
||||
<div class="h-10 w-10 flex items-center justify-center">
|
||||
<IconBookmark class="h-7 w-7 text-white" />
|
||||
<div class="w-10 h-10 flex items-center justify-center">
|
||||
<svg class="w-7 h-7 text-white drop-shadow-md" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M17 3H7c-1.1 0-2 .9-2 2v16l7-3 7 3V5c0-1.1-.9-2-2-2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-white text-xs font-semibold">45K</span>
|
||||
<span class="text-white text-[11px] font-medium drop-shadow">{{ formatNumber(45200) }}</span>
|
||||
</button>
|
||||
|
||||
<!-- Share -->
|
||||
<button class="flex flex-col items-center">
|
||||
<div class="h-10 w-10 flex items-center justify-center">
|
||||
<IconShare class="h-7 w-7 text-white" />
|
||||
<div class="w-10 h-10 flex items-center justify-center">
|
||||
<svg class="w-7 h-7 text-white drop-shadow-md" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path
|
||||
d="M12 2L15.09 8.26L22 9.27L17 14.14L18.18 21.02L12 17.77L5.82 21.02L7 14.14L2 9.27L8.91 8.26L12 2Z"
|
||||
transform="rotate(90 12 12)" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-white text-xs font-semibold">Share</span>
|
||||
<span class="text-white text-[11px] font-medium drop-shadow">Share</span>
|
||||
</button>
|
||||
|
||||
<!-- Music disc -->
|
||||
<div class="h-10 w-10 rounded-full bg-gray-800 border-2 border-gray-600 flex items-center justify-center animate-spin" style="animation-duration: 3s;">
|
||||
<img
|
||||
v-if="socialAccount.avatar_url"
|
||||
:src="socialAccount.avatar_url"
|
||||
class="h-6 w-6 rounded-full object-cover"
|
||||
/>
|
||||
<IconMusic v-else class="h-4 w-4 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom info -->
|
||||
<div class="absolute left-3 right-16 bottom-4 text-white">
|
||||
<p class="font-semibold text-sm">@{{ socialAccount.username || socialAccount.display_name }}</p>
|
||||
<div class="mt-1">
|
||||
<div v-if="props.readonly" class="w-full min-h-[40px] max-h-[80px] text-sm text-white whitespace-pre-wrap">
|
||||
{{ content || 'No content' }}
|
||||
</div>
|
||||
<textarea
|
||||
v-else
|
||||
:value="content"
|
||||
@input="emit('update:content', ($event.target as HTMLTextAreaElement).value)"
|
||||
class="w-full min-h-[40px] max-h-[80px] bg-transparent border-0 p-0 text-sm text-white resize-none focus:outline-none focus:ring-0 placeholder:text-gray-400"
|
||||
placeholder="Add a caption..."
|
||||
/>
|
||||
<div v-if="media.length > 0" class="absolute left-3 right-14 bottom-[72px] text-white z-10">
|
||||
<!-- Username -->
|
||||
<p class="font-bold text-[14px] drop-shadow-lg mb-1">@{{ username }}</p>
|
||||
|
||||
<!-- Caption -->
|
||||
<div v-if="content" class="text-[13px] text-white drop-shadow-lg line-clamp-2 leading-[18px] mb-2">
|
||||
{{ content }}
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mt-2 text-sm">
|
||||
<IconMusic class="h-4 w-4" />
|
||||
<span class="truncate">Original sound - {{ socialAccount.display_name }}</span>
|
||||
|
||||
<!-- Music ticker -->
|
||||
<div class="flex items-center gap-1.5">
|
||||
<svg class="w-3 h-3 shrink-0" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z" />
|
||||
</svg>
|
||||
<div class="flex items-center overflow-hidden">
|
||||
<p class="text-[12px] whitespace-nowrap">
|
||||
<span class="inline-flex animate-marquee">
|
||||
Original sound - {{ socialAccount.display_name }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer with char count (only in edit mode) -->
|
||||
<div v-if="!props.readonly" class="absolute top-0 left-0 right-0 p-3 flex items-center justify-between bg-gradient-to-b from-black/50 to-transparent">
|
||||
<span
|
||||
class="text-xs px-2 py-1 rounded-full"
|
||||
:class="isValid ? 'bg-green-500/30 text-green-300' : 'bg-red-500/30 text-red-300'"
|
||||
>
|
||||
{{ validationMessage }}
|
||||
</span>
|
||||
<label v-if="media.length > 0" class="cursor-pointer p-2 rounded-full bg-black/30 hover:bg-black/50 transition-colors">
|
||||
<input
|
||||
type="file"
|
||||
accept="video/*,image/*"
|
||||
class="hidden"
|
||||
@change="emit('upload', $event)"
|
||||
:disabled="isUploading"
|
||||
/>
|
||||
<IconPhoto class="h-5 w-5 text-white" />
|
||||
</label>
|
||||
<!-- Bottom Navigation Bar -->
|
||||
<div v-if="media.length > 0"
|
||||
class="absolute bottom-0 left-0 right-0 h-[52px] bg-black flex items-center justify-around px-2 z-20 border-t border-white/5">
|
||||
<!-- Home -->
|
||||
<button class="flex flex-col items-center justify-center py-1">
|
||||
<svg class="w-6 h-6 text-white" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 3L4 9v12h16V9l-8-6z" />
|
||||
</svg>
|
||||
<span class="text-[10px] text-white mt-0.5">Home</span>
|
||||
</button>
|
||||
|
||||
<!-- Friends -->
|
||||
<button class="flex flex-col items-center justify-center py-1">
|
||||
<svg class="w-6 h-6 text-white/60" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path
|
||||
d="M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5s-3 1.34-3 3 1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z" />
|
||||
</svg>
|
||||
<span class="text-[10px] text-white/60 mt-0.5">Friends</span>
|
||||
</button>
|
||||
|
||||
<!-- Create (center button) -->
|
||||
<button class="flex items-center justify-center">
|
||||
<div class="w-11 h-7 relative">
|
||||
<div class="absolute inset-0 bg-[#00f2ea] rounded-lg translate-x-[3px]"></div>
|
||||
<div class="absolute inset-0 bg-[#fe2c55] rounded-lg -translate-x-[3px]"></div>
|
||||
<div class="absolute inset-0 bg-white rounded-lg flex items-center justify-center">
|
||||
<IconPlus class="w-5 h-5 text-black" stroke-width="2.5" />
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- Inbox -->
|
||||
<button class="flex flex-col items-center justify-center py-1">
|
||||
<svg class="w-6 h-6 text-white/60" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path
|
||||
d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z" />
|
||||
</svg>
|
||||
<span class="text-[10px] text-white/60 mt-0.5">Inbox</span>
|
||||
</button>
|
||||
|
||||
<!-- Profile -->
|
||||
<button class="flex flex-col items-center justify-center py-1">
|
||||
<svg class="w-6 h-6 text-white/60" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path
|
||||
d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" />
|
||||
</svg>
|
||||
<span class="text-[10px] text-white/60 mt-0.5">Profile</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Video progress bar (above nav) -->
|
||||
<div v-if="media.length > 0" class="absolute bottom-[52px] left-0 right-0 h-[2px] bg-white/20 z-20">
|
||||
<div class="h-full w-1/3 bg-white rounded-full"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@keyframes marquee {
|
||||
0% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-marquee {
|
||||
animation: marquee 8s linear infinite;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,15 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import {
|
||||
IconDots,
|
||||
IconX,
|
||||
IconPhoto,
|
||||
IconHeart,
|
||||
IconMessageCircle,
|
||||
IconRepeat,
|
||||
IconChartBar,
|
||||
IconBookmark,
|
||||
IconShare,
|
||||
} from '@tabler/icons-vue';
|
||||
import { computed } from 'vue';
|
||||
|
||||
interface SocialAccount {
|
||||
id: string;
|
||||
|
|
@ -30,183 +20,156 @@ interface Props {
|
|||
socialAccount: SocialAccount;
|
||||
content: string;
|
||||
media: MediaItem[];
|
||||
charCount: number;
|
||||
maxLength: number;
|
||||
isValid: boolean;
|
||||
validationMessage: string;
|
||||
isUploading?: boolean;
|
||||
readonly?: boolean;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:content': [value: string];
|
||||
'upload': [event: Event];
|
||||
'remove-media': [mediaId: string];
|
||||
}>();
|
||||
const username = computed(() => props.socialAccount.username || 'username');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bg-white dark:bg-black rounded-xl overflow-hidden border border-gray-200 dark:border-gray-800">
|
||||
<!-- Post Content -->
|
||||
<div class="p-4">
|
||||
<div class="flex gap-3">
|
||||
<!-- Avatar -->
|
||||
<div class="shrink-0">
|
||||
<img
|
||||
v-if="socialAccount.avatar_url"
|
||||
:src="socialAccount.avatar_url"
|
||||
:alt="socialAccount.display_name"
|
||||
class="h-10 w-10 rounded-full object-cover"
|
||||
/>
|
||||
<div v-else class="h-10 w-10 rounded-full bg-gray-800 flex items-center justify-center text-white font-bold">
|
||||
{{ socialAccount.display_name?.charAt(0) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full h-full bg-white dark:bg-black text-[#0f1419] dark:text-[#e7e9ea] overflow-hidden flex flex-col">
|
||||
<!-- Mobile Header -->
|
||||
<div
|
||||
class="flex-shrink-0 h-11 flex items-center justify-between px-4 border-b border-[#eff3f4] dark:border-[#2f3336]">
|
||||
<!-- Back arrow -->
|
||||
<button class="p-1 -ml-1">
|
||||
<svg class="h-5 w-5 text-[#0f1419] dark:text-[#e7e9ea]" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2">
|
||||
<path d="M19 12H5M12 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Title -->
|
||||
<span class="text-[17px] font-bold">Post</span>
|
||||
<!-- Tabs icon -->
|
||||
<button class="p-1 -mr-1">
|
||||
<svg class="h-5 w-5 text-[#0f1419] dark:text-[#e7e9ea]" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path
|
||||
d="M4 4.5C4 3.12 5.12 2 6.5 2h11C18.88 2 20 3.12 20 4.5v15c0 1.38-1.12 2.5-2.5 2.5h-11C5.12 22 4 20.88 4 19.5v-15zM6.5 4c-.28 0-.5.22-.5.5v15c0 .28.22.5.5.5h11c.28 0 .5-.22.5-.5v-15c0-.28-.22-.5-.5-.5h-11zM8 7h8v2H8V7z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Content Area -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-1 text-sm">
|
||||
<span class="font-bold text-gray-900 dark:text-white hover:underline cursor-pointer">
|
||||
<!-- Main Content -->
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<!-- Post -->
|
||||
<div class="px-4 pt-3">
|
||||
<!-- Author row -->
|
||||
<div class="flex items-center gap-2.5">
|
||||
<!-- Avatar -->
|
||||
<img v-if="socialAccount.avatar_url" :src="socialAccount.avatar_url"
|
||||
:alt="socialAccount.display_name" class="h-10 w-10 rounded-full object-cover flex-shrink-0" />
|
||||
<div v-else
|
||||
class="h-10 w-10 rounded-full bg-[#1d9bf0] flex items-center justify-center text-white font-bold flex-shrink-0">
|
||||
{{ socialAccount.display_name?.charAt(0).toUpperCase() }}
|
||||
</div>
|
||||
|
||||
<!-- Name + Username column -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="font-bold text-[15px] text-[#0f1419] dark:text-[#e7e9ea] truncate">
|
||||
{{ socialAccount.display_name }}
|
||||
</span>
|
||||
<span class="text-gray-500">@{{ socialAccount.username || 'username' }}</span>
|
||||
<span class="text-gray-500">·</span>
|
||||
<span class="text-gray-500 hover:underline cursor-pointer">now</span>
|
||||
<!-- Verified Badge -->
|
||||
<svg class="h-[18px] w-[18px] text-[#1d9bf0] flex-shrink-0" viewBox="0 0 22 22"
|
||||
fill="currentColor">
|
||||
<path
|
||||
d="M20.396 11c-.018-.646-.215-1.275-.57-1.816-.354-.54-.852-.972-1.438-1.246.223-.607.27-1.264.14-1.897-.131-.634-.437-1.218-.882-1.687-.47-.445-1.053-.75-1.687-.882-.633-.13-1.29-.083-1.897.14-.273-.587-.704-1.086-1.245-1.44S11.647 1.62 11 1.604c-.646.017-1.273.213-1.813.568s-.969.854-1.24 1.44c-.608-.223-1.267-.272-1.902-.14-.635.13-1.22.436-1.69.882-.445.47-.749 1.055-.878 1.688-.13.633-.08 1.29.144 1.896-.587.274-1.087.705-1.443 1.245-.356.54-.555 1.17-.574 1.817.02.647.218 1.276.574 1.817.356.54.856.972 1.443 1.245-.224.606-.274 1.263-.144 1.896.13.634.433 1.218.877 1.688.47.443 1.054.747 1.687.878.633.132 1.29.084 1.897-.136.274.586.705 1.084 1.246 1.439.54.354 1.17.551 1.816.569.647-.016 1.276-.213 1.817-.567s.972-.854 1.245-1.44c.604.239 1.266.296 1.903.164.636-.132 1.22-.447 1.68-.907.46-.46.776-1.044.908-1.681s.075-1.299-.165-1.903c.586-.274 1.084-.705 1.439-1.246.354-.54.551-1.17.569-1.816zM9.662 14.85l-3.429-3.428 1.293-1.302 2.072 2.072 4.4-4.794 1.347 1.246z" />
|
||||
</svg>
|
||||
</div>
|
||||
<button class="p-1.5 hover:bg-gray-100 dark:hover:bg-gray-900 rounded-full -mr-1.5">
|
||||
<IconDots class="h-5 w-5 text-gray-500" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tweet Content -->
|
||||
<div class="mt-1">
|
||||
<div v-if="props.readonly" class="w-full min-h-[80px] text-[15px] text-gray-900 dark:text-white whitespace-pre-wrap">
|
||||
{{ content || 'No content' }}
|
||||
</div>
|
||||
<textarea
|
||||
v-else
|
||||
:value="content"
|
||||
@input="emit('update:content', ($event.target as HTMLTextAreaElement).value)"
|
||||
class="w-full min-h-[80px] bg-transparent border-0 p-0 text-[15px] text-gray-900 dark:text-white resize-none focus:outline-none focus:ring-0 placeholder:text-gray-500"
|
||||
placeholder="What's happening?"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Media -->
|
||||
<div v-if="media.length > 0" class="mt-3">
|
||||
<div
|
||||
class="grid gap-0.5 rounded-2xl overflow-hidden"
|
||||
:class="{
|
||||
'grid-cols-1': media.length === 1,
|
||||
'grid-cols-2': media.length >= 2,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
v-for="(item, index) in media.slice(0, 4)"
|
||||
:key="item.id"
|
||||
class="relative group overflow-hidden"
|
||||
:class="{
|
||||
'aspect-video': media.length === 1,
|
||||
'aspect-square': media.length > 1,
|
||||
'row-span-2': media.length === 3 && index === 0,
|
||||
}"
|
||||
>
|
||||
<img
|
||||
v-if="item.type === 'image'"
|
||||
:src="item.url"
|
||||
:alt="item.original_filename"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
<video
|
||||
v-else
|
||||
:src="item.url"
|
||||
class="w-full h-full object-cover bg-black"
|
||||
muted
|
||||
loop
|
||||
playsinline
|
||||
/>
|
||||
<button
|
||||
v-if="!props.readonly"
|
||||
type="button"
|
||||
@click="emit('remove-media', item.id)"
|
||||
class="absolute top-2 right-2 bg-black/70 text-white rounded-full p-1.5 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<IconX class="h-4 w-4" />
|
||||
</button>
|
||||
<div
|
||||
v-if="media.length > 4 && index === 3"
|
||||
class="absolute inset-0 bg-black/60 flex items-center justify-center"
|
||||
>
|
||||
<span class="text-white text-2xl font-semibold">+{{ media.length - 4 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Engagement Actions -->
|
||||
<div class="flex items-center justify-between mt-3 max-w-md text-gray-500">
|
||||
<button class="flex items-center gap-1 group">
|
||||
<div class="p-2 rounded-full group-hover:bg-blue-500/10">
|
||||
<IconMessageCircle class="h-[18px] w-[18px] group-hover:text-blue-500" />
|
||||
</div>
|
||||
<span class="text-sm group-hover:text-blue-500">12</span>
|
||||
</button>
|
||||
<button class="flex items-center gap-1 group">
|
||||
<div class="p-2 rounded-full group-hover:bg-green-500/10">
|
||||
<IconRepeat class="h-[18px] w-[18px] group-hover:text-green-500" />
|
||||
</div>
|
||||
<span class="text-sm group-hover:text-green-500">5</span>
|
||||
</button>
|
||||
<button class="flex items-center gap-1 group">
|
||||
<div class="p-2 rounded-full group-hover:bg-pink-500/10">
|
||||
<IconHeart class="h-[18px] w-[18px] group-hover:text-pink-500" />
|
||||
</div>
|
||||
<span class="text-sm group-hover:text-pink-500">89</span>
|
||||
</button>
|
||||
<button class="flex items-center gap-1 group">
|
||||
<div class="p-2 rounded-full group-hover:bg-blue-500/10">
|
||||
<IconChartBar class="h-[18px] w-[18px] group-hover:text-blue-500" />
|
||||
</div>
|
||||
<span class="text-sm group-hover:text-blue-500">1.2K</span>
|
||||
</button>
|
||||
<div class="flex items-center gap-1">
|
||||
<button class="p-2 rounded-full hover:bg-blue-500/10 group">
|
||||
<IconBookmark class="h-[18px] w-[18px] group-hover:text-blue-500" />
|
||||
</button>
|
||||
<button class="p-2 rounded-full hover:bg-blue-500/10 group">
|
||||
<IconShare class="h-[18px] w-[18px] group-hover:text-blue-500" />
|
||||
</button>
|
||||
<div class="text-[14px] text-[#536471] dark:text-[#71767b]">
|
||||
@{{ username }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer with upload and char count (only in edit mode) -->
|
||||
<div v-if="!props.readonly" class="border-t border-gray-200 dark:border-gray-800 px-4 py-3 flex items-center justify-between bg-gray-50 dark:bg-gray-900/50">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="text-xs px-2 py-1 rounded-full"
|
||||
:class="isValid ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' : 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'"
|
||||
>
|
||||
{{ validationMessage }}
|
||||
</span>
|
||||
<!-- Content -->
|
||||
<div v-if="content" class="mt-3 text-[17px] text-[#0f1419] dark:text-[#e7e9ea] whitespace-pre-wrap leading-[22px]">
|
||||
{{ content }}
|
||||
</div>
|
||||
|
||||
<!-- Media -->
|
||||
<div v-if="media.length > 0" class="mt-3">
|
||||
<div class="rounded-2xl overflow-hidden border border-[#cfd9de] dark:border-[#2f3336]" :class="{
|
||||
'grid grid-cols-2 gap-0.5': media.length >= 2,
|
||||
}">
|
||||
<div v-for="(item, index) in media.slice(0, 4)" :key="item.id"
|
||||
class="relative overflow-hidden" :class="{
|
||||
'aspect-[16/9]': media.length === 1,
|
||||
'aspect-square': media.length > 1,
|
||||
}">
|
||||
<img v-if="item.type === 'image'" :src="item.url" :alt="item.original_filename"
|
||||
class="w-full h-full object-cover" />
|
||||
<video v-else :src="item.url" class="w-full h-full object-cover bg-black" muted loop
|
||||
playsinline />
|
||||
<!-- Video duration badge -->
|
||||
<div v-if="item.type === 'video'"
|
||||
class="absolute bottom-2 left-2 bg-black/70 text-white text-[13px] px-1.5 py-0.5 rounded">
|
||||
0:27
|
||||
</div>
|
||||
<div v-if="media.length > 4 && index === 3"
|
||||
class="absolute inset-0 bg-black/60 flex items-center justify-center">
|
||||
<span class="text-white text-2xl font-bold">+{{ media.length - 4 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Timestamp & Views -->
|
||||
<div class="mt-3 text-[15px] text-[#536471] dark:text-[#71767b]">
|
||||
<span>4:21 PM · Jan 20, 2026</span>
|
||||
<span class="mx-1">·</span>
|
||||
<span class="text-[#0f1419] dark:text-[#e7e9ea] font-bold">3.5M</span>
|
||||
<span> Views</span>
|
||||
</div>
|
||||
</div>
|
||||
<label class="cursor-pointer p-2 rounded-full hover:bg-blue-500/10 transition-colors">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*,video/*"
|
||||
multiple
|
||||
class="hidden"
|
||||
@change="emit('upload', $event)"
|
||||
:disabled="isUploading"
|
||||
/>
|
||||
<IconPhoto class="h-5 w-5 text-blue-500" />
|
||||
</label>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="border-t border-[#eff3f4] dark:border-[#2f3336] mt-3">
|
||||
<div class="flex items-center justify-around py-1">
|
||||
<!-- Comment -->
|
||||
<button class="flex items-center gap-1 p-2 group">
|
||||
<svg class="h-5 w-5 text-[#536471] dark:text-[#71767b] group-hover:text-[#1d9bf0]"
|
||||
viewBox="0 0 24 24" fill="currentColor">
|
||||
<path
|
||||
d="M1.751 10c0-4.42 3.584-8 8.005-8h4.366c4.49 0 8.129 3.64 8.129 8.13 0 2.96-1.607 5.68-4.196 7.11l-8.054 4.46v-3.69h-.067c-4.49.1-8.183-3.51-8.183-8.01zm8.005-6c-3.317 0-6.005 2.69-6.005 6 0 3.37 2.77 6.08 6.138 6.01l.351-.01h1.761v2.3l5.087-2.81c1.951-1.08 3.163-3.13 3.163-5.36 0-3.39-2.744-6.13-6.129-6.13H9.756z" />
|
||||
</svg>
|
||||
<span
|
||||
class="text-[13px] text-[#536471] dark:text-[#71767b] group-hover:text-[#1d9bf0]">1.2K</span>
|
||||
</button>
|
||||
<!-- Repost -->
|
||||
<button class="flex items-center gap-1 p-2 group">
|
||||
<svg class="h-5 w-5 text-[#536471] dark:text-[#71767b] group-hover:text-[#00ba7c]"
|
||||
viewBox="0 0 24 24" fill="currentColor">
|
||||
<path
|
||||
d="M4.5 3.88l4.432 4.14-1.364 1.46L5.5 7.55V16c0 1.1.896 2 2 2H13v2H7.5c-2.209 0-4-1.79-4-4V7.55L1.432 9.48.068 8.02 4.5 3.88zM16.5 6H11V4h5.5c2.209 0 4 1.79 4 4v8.45l2.068-1.93 1.364 1.46-4.432 4.14-4.432-4.14 1.364-1.46 2.068 1.93V8c0-1.1-.896-2-2-2z" />
|
||||
</svg>
|
||||
<span
|
||||
class="text-[13px] text-[#536471] dark:text-[#71767b] group-hover:text-[#00ba7c]">2.5K</span>
|
||||
</button>
|
||||
<!-- Like -->
|
||||
<button class="flex items-center gap-1 p-2 group">
|
||||
<svg class="h-5 w-5 text-[#536471] dark:text-[#71767b] group-hover:text-[#f91880]"
|
||||
viewBox="0 0 24 24" fill="currentColor">
|
||||
<path
|
||||
d="M16.697 5.5c-1.222-.06-2.679.51-3.89 2.16l-.805 1.09-.806-1.09C9.984 6.01 8.526 5.44 7.304 5.5c-1.243.07-2.349.78-2.91 1.91-.552 1.12-.633 2.78.479 4.82 1.074 1.97 3.257 4.27 7.129 6.61 3.87-2.34 6.052-4.64 7.126-6.61 1.111-2.04 1.03-3.7.477-4.82-.561-1.13-1.666-1.84-2.908-1.91zm4.187 7.69c-1.351 2.48-4.001 5.12-8.379 7.67l-.503.3-.504-.3c-4.379-2.55-7.029-5.19-8.382-7.67-1.36-2.5-1.41-4.86-.514-6.67.887-1.79 2.647-2.91 4.601-3.01 1.651-.09 3.368.56 4.798 2.01 1.429-1.45 3.146-2.1 4.796-2.01 1.954.1 3.714 1.22 4.601 3.01.896 1.81.846 4.17-.514 6.67z" />
|
||||
</svg>
|
||||
<span
|
||||
class="text-[13px] text-[#536471] dark:text-[#71767b] group-hover:text-[#f91880]">11K</span>
|
||||
</button>
|
||||
<!-- Bookmark -->
|
||||
<button class="flex items-center gap-1 p-2 group">
|
||||
<svg class="h-5 w-5 text-[#536471] dark:text-[#71767b] group-hover:text-[#1d9bf0]"
|
||||
viewBox="0 0 24 24" fill="currentColor">
|
||||
<path
|
||||
d="M4 4.5C4 3.12 5.119 2 6.5 2h11C18.881 2 20 3.12 20 4.5v18.44l-8-5.71-8 5.71V4.5zM6.5 4c-.276 0-.5.22-.5.5v14.56l6-4.29 6 4.29V4.5c0-.28-.224-.5-.5-.5h-11z" />
|
||||
</svg>
|
||||
<span
|
||||
class="text-[13px] text-[#536471] dark:text-[#71767b] group-hover:text-[#1d9bf0]">4.6K</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
|
@ -1,14 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import {
|
||||
IconVideo,
|
||||
IconX,
|
||||
IconPhoto,
|
||||
IconThumbUp,
|
||||
IconThumbDown,
|
||||
IconMessageCircle,
|
||||
IconShare,
|
||||
IconDots,
|
||||
} from '@tabler/icons-vue';
|
||||
import { computed } from 'vue';
|
||||
|
||||
interface SocialAccount {
|
||||
id: string;
|
||||
|
|
@ -29,183 +20,219 @@ interface Props {
|
|||
socialAccount: SocialAccount;
|
||||
content: string;
|
||||
media: MediaItem[];
|
||||
charCount: number;
|
||||
maxLength: number;
|
||||
isValid: boolean;
|
||||
validationMessage: string;
|
||||
isUploading?: boolean;
|
||||
readonly?: boolean;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:content': [value: string];
|
||||
'upload': [event: Event];
|
||||
'remove-media': [mediaId: string];
|
||||
}>();
|
||||
// Format engagement numbers like YouTube does
|
||||
const formatNumber = (num: number): string => {
|
||||
if (num >= 1000000) {
|
||||
return (num / 1000000).toFixed(1).replace(/\.0$/, '') + 'M';
|
||||
}
|
||||
if (num >= 1000) {
|
||||
return (num / 1000).toFixed(1).replace(/\.0$/, '') + 'K';
|
||||
}
|
||||
return num.toString();
|
||||
};
|
||||
|
||||
const username = computed(() => props.socialAccount.username || props.socialAccount.display_name);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bg-[#0f0f0f] rounded-xl overflow-hidden relative" style="aspect-ratio: 9/16; max-height: 600px;">
|
||||
<!-- Video Area -->
|
||||
<div class="w-full h-full bg-[#0f0f0f] text-white overflow-hidden flex flex-col relative">
|
||||
<!-- Video/Media Area - Full screen -->
|
||||
<div class="absolute inset-0">
|
||||
<!-- Video content -->
|
||||
<div v-if="media.length > 0 && media[0].type === 'video'" class="w-full h-full">
|
||||
<video :src="media[0].url" class="w-full h-full object-cover" muted loop playsinline />
|
||||
<button
|
||||
v-if="!props.readonly"
|
||||
type="button"
|
||||
@click="emit('remove-media', media[0].id)"
|
||||
class="absolute top-4 right-4 bg-black/50 text-white rounded-full p-2"
|
||||
>
|
||||
<IconX class="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div v-else-if="media.length > 0 && media[0].type === 'image'" class="w-full h-full">
|
||||
<img :src="media[0].url" :alt="media[0].original_filename" class="w-full h-full object-cover" />
|
||||
<button
|
||||
v-if="!props.readonly"
|
||||
type="button"
|
||||
@click="emit('remove-media', media[0].id)"
|
||||
class="absolute top-4 right-4 bg-black/50 text-white rounded-full p-2"
|
||||
>
|
||||
<IconX class="h-5 w-5" />
|
||||
</div>
|
||||
<div v-else class="w-full h-full flex items-center justify-center bg-[#0f0f0f]">
|
||||
<!-- YouTube Shorts icon -->
|
||||
<svg class="h-12 w-12 text-white/20" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path
|
||||
d="M10 14.65v-5.3L15 12l-5 2.65zm7.77-4.33c-.77-.32-1.2-.5-1.2-.5L18 9.06c1.84-.96 2.53-3.23 1.56-5.06s-3.24-2.53-5.07-1.56L6 6.94c-1.29.68-2.07 2.04-2 3.49.07 1.42.93 2.67 2.22 3.25.03.01 1.2.5 1.2.5L6 14.93c-1.83.97-2.53 3.24-1.56 5.07.97 1.83 3.24 2.53 5.07 1.56l8.5-4.5c1.29-.68 2.06-2.04 1.99-3.49-.07-1.42-.94-2.68-2.23-3.25z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Top Header -->
|
||||
<div v-if="media.length > 0"
|
||||
class="absolute top-0 left-0 right-0 px-3 pt-1 flex items-center justify-between z-10">
|
||||
<!-- Shorts logo -->
|
||||
<div class="flex items-center gap-1">
|
||||
<svg class="h-5 w-auto" viewBox="0 0 24 24" fill="#ff0000">
|
||||
<path
|
||||
d="M10 14.65v-5.3L15 12l-5 2.65zm7.77-4.33c-.77-.32-1.2-.5-1.2-.5L18 9.06c1.84-.96 2.53-3.23 1.56-5.06s-3.24-2.53-5.07-1.56L6 6.94c-1.29.68-2.07 2.04-2 3.49.07 1.42.93 2.67 2.22 3.25.03.01 1.2.5 1.2.5L6 14.93c-1.83.97-2.53 3.24-1.56 5.07.97 1.83 3.24 2.53 5.07 1.56l8.5-4.5c1.29-.68 2.06-2.04 1.99-3.49-.07-1.42-.94-2.68-2.23-3.25z" />
|
||||
</svg>
|
||||
<span class="text-white font-medium text-[14px]">Shorts</span>
|
||||
</div>
|
||||
|
||||
<!-- Search and camera icons -->
|
||||
<div class="flex items-center gap-4">
|
||||
<button>
|
||||
<svg class="w-5 h-5 text-white" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2">
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path d="m21 21-4.35-4.35" />
|
||||
</svg>
|
||||
</button>
|
||||
<button>
|
||||
<svg class="w-5 h-5 text-white" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path
|
||||
d="M17 10.5V7c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div v-else-if="!props.readonly" class="w-full h-full flex flex-col items-center justify-center bg-[#0f0f0f] text-gray-400">
|
||||
<IconVideo class="h-20 w-20 mb-4" />
|
||||
<p class="text-sm mb-2">Add a Short video</p>
|
||||
<label class="cursor-pointer bg-[#ff0000] text-white px-6 py-2 rounded-full font-semibold text-sm hover:bg-[#cc0000] transition-colors">
|
||||
<input
|
||||
type="file"
|
||||
accept="video/*,image/*"
|
||||
class="hidden"
|
||||
@change="emit('upload', $event)"
|
||||
:disabled="isUploading"
|
||||
/>
|
||||
Upload
|
||||
</label>
|
||||
</div>
|
||||
<div v-else class="w-full h-full flex flex-col items-center justify-center bg-[#0f0f0f] text-gray-400">
|
||||
<IconVideo class="h-20 w-20 mb-4" />
|
||||
<p class="text-sm">No media</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Gradient overlay at bottom -->
|
||||
<div class="absolute inset-x-0 bottom-0 h-1/2 bg-gradient-to-t from-black/80 via-black/40 to-transparent pointer-events-none" />
|
||||
<div v-if="media.length > 0"
|
||||
class="absolute inset-x-0 bottom-0 h-56 bg-gradient-to-t from-black/80 via-black/40 to-transparent pointer-events-none z-[5]" />
|
||||
|
||||
<!-- Right side actions -->
|
||||
<div class="absolute right-3 bottom-28 flex flex-col items-center gap-5">
|
||||
<div v-if="media.length > 0" class="absolute right-2 bottom-[72px] flex flex-col items-center gap-3 z-10">
|
||||
<!-- Like -->
|
||||
<button class="flex flex-col items-center">
|
||||
<div class="h-12 w-12 bg-[#272727] rounded-full flex items-center justify-center">
|
||||
<IconThumbUp class="h-6 w-6 text-white" />
|
||||
<div class="w-11 h-11 bg-[#272727]/80 backdrop-blur-sm rounded-full flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-white" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path
|
||||
d="M1 21h4V9H1v12zm22-11c0-1.1-.9-2-2-2h-6.31l.95-4.57.03-.32c0-.41-.17-.79-.44-1.06L14.17 1 7.59 7.59C7.22 7.95 7 8.45 7 9v10c0 1.1.9 2 2 2h9c.83 0 1.54-.5 1.84-1.22l3.02-7.05c.09-.23.14-.47.14-.73v-2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-white text-xs mt-1">12K</span>
|
||||
<span class="text-white text-[11px] mt-1 font-medium">{{ formatNumber(12400) }}</span>
|
||||
</button>
|
||||
|
||||
<!-- Dislike -->
|
||||
<button class="flex flex-col items-center">
|
||||
<div class="h-12 w-12 bg-[#272727] rounded-full flex items-center justify-center">
|
||||
<IconThumbDown class="h-6 w-6 text-white" />
|
||||
<div class="w-11 h-11 bg-[#272727]/80 backdrop-blur-sm rounded-full flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-white" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path
|
||||
d="M15 3H6c-.83 0-1.54.5-1.84 1.22l-3.02 7.05c-.09.23-.14.47-.14.73v2c0 1.1.9 2 2 2h6.31l-.95 4.57-.03.32c0 .41.17.79.44 1.06L9.83 23l6.59-6.59c.36-.36.58-.86.58-1.41V5c0-1.1-.9-2-2-2zm4 0v12h4V3h-4z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-white text-xs mt-1">Dislike</span>
|
||||
<span class="text-white text-[11px] mt-1 font-medium">Dislike</span>
|
||||
</button>
|
||||
|
||||
<!-- Comment -->
|
||||
<!-- Comments -->
|
||||
<button class="flex flex-col items-center">
|
||||
<div class="h-12 w-12 bg-[#272727] rounded-full flex items-center justify-center">
|
||||
<IconMessageCircle class="h-6 w-6 text-white" />
|
||||
<div class="w-11 h-11 bg-[#272727]/80 backdrop-blur-sm rounded-full flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-white" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M21.99 4c0-1.1-.89-2-1.99-2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h14l4 4-.01-18z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-white text-xs mt-1">234</span>
|
||||
<span class="text-white text-[11px] mt-1 font-medium">{{ formatNumber(234) }}</span>
|
||||
</button>
|
||||
|
||||
<!-- Share -->
|
||||
<button class="flex flex-col items-center">
|
||||
<div class="h-12 w-12 bg-[#272727] rounded-full flex items-center justify-center">
|
||||
<IconShare class="h-6 w-6 text-white" />
|
||||
<div class="w-11 h-11 bg-[#272727]/80 backdrop-blur-sm rounded-full flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-white" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path
|
||||
d="M13.12 2.06L7.58 7.6c-.37.37-.58.88-.58 1.41V19c0 1.1.9 2 2 2h9c.8 0 1.52-.48 1.84-1.21l3.26-7.61C23.94 10.2 22.49 8 20.34 8h-5.65l.95-4.58c.1-.5-.05-1.01-.41-1.37-.59-.58-1.53-.58-2.11.01zM3 21c1.1 0 2-.9 2-2v-8c0-1.1-.9-2-2-2s-2 .9-2 2v8c0 1.1.9 2 2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-white text-xs mt-1">Share</span>
|
||||
<span class="text-white text-[11px] mt-1 font-medium">Share</span>
|
||||
</button>
|
||||
|
||||
<!-- More -->
|
||||
<!-- Remix -->
|
||||
<button class="flex flex-col items-center">
|
||||
<div class="h-12 w-12 bg-[#272727] rounded-full flex items-center justify-center">
|
||||
<IconDots class="h-6 w-6 text-white" />
|
||||
<div class="w-11 h-11 bg-[#272727]/80 backdrop-blur-sm rounded-full flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-white" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path
|
||||
d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-white text-[11px] mt-1 font-medium">Remix</span>
|
||||
</button>
|
||||
|
||||
<!-- Channel avatar -->
|
||||
<div class="relative mt-1">
|
||||
<img
|
||||
v-if="socialAccount.avatar_url"
|
||||
:src="socialAccount.avatar_url"
|
||||
:alt="socialAccount.display_name"
|
||||
class="h-10 w-10 rounded-full object-cover border-2 border-white"
|
||||
/>
|
||||
<div v-else class="h-10 w-10 rounded-full bg-[#ff0000] flex items-center justify-center text-white font-bold border-2 border-white text-sm">
|
||||
{{ socialAccount.display_name?.charAt(0) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom info -->
|
||||
<div class="absolute left-3 right-20 bottom-4 text-white">
|
||||
<div v-if="media.length > 0" class="absolute left-3 right-14 bottom-[72px] text-white z-10">
|
||||
<!-- Channel info and subscribe -->
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<img
|
||||
v-if="socialAccount.avatar_url"
|
||||
:src="socialAccount.avatar_url"
|
||||
:alt="socialAccount.display_name"
|
||||
class="h-8 w-8 rounded-full object-cover"
|
||||
/>
|
||||
<div v-else class="h-8 w-8 rounded-full bg-[#ff0000] flex items-center justify-center text-white font-bold text-xs">
|
||||
{{ socialAccount.display_name?.charAt(0) }}
|
||||
<div class="w-8 h-8 rounded-full overflow-hidden flex-shrink-0">
|
||||
<img v-if="socialAccount.avatar_url" :src="socialAccount.avatar_url"
|
||||
:alt="socialAccount.display_name" class="w-full h-full object-cover" />
|
||||
<div v-else
|
||||
class="w-full h-full bg-[#ff0000] flex items-center justify-center text-white font-bold text-[10px]">
|
||||
{{ socialAccount.display_name?.charAt(0).toUpperCase() }}
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-sm font-medium">@{{ socialAccount.username || socialAccount.display_name }}</span>
|
||||
<button class="ml-1 bg-white text-black text-xs font-semibold px-3 py-1 rounded-full hover:bg-gray-200">
|
||||
<span class="text-[13px] font-medium">@{{ username }}</span>
|
||||
<button class="ml-auto bg-white text-black text-[12px] font-medium px-3 py-1 rounded-full">
|
||||
Subscribe
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<div v-if="props.readonly" class="w-full min-h-[40px] max-h-[80px] text-sm text-white whitespace-pre-wrap">
|
||||
{{ content || 'No content' }}
|
||||
|
||||
<!-- Video title -->
|
||||
<div class="text-[13px] text-white line-clamp-2 leading-[18px] mb-1.5">
|
||||
{{ content || 'No title' }}
|
||||
</div>
|
||||
|
||||
<!-- Music info -->
|
||||
<div class="flex items-center gap-1.5">
|
||||
<svg class="w-3 h-3 shrink-0" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z" />
|
||||
</svg>
|
||||
<span class="text-[11px] text-white/80 truncate">{{ socialAccount.display_name }} - Original
|
||||
audio</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom Navigation Bar -->
|
||||
<div v-if="media.length > 0"
|
||||
class="absolute bottom-0 left-0 right-0 h-[52px] bg-[#0f0f0f] flex items-center justify-around px-1 z-20 border-t border-white/10">
|
||||
<!-- Home -->
|
||||
<button class="flex flex-col items-center justify-center py-1 min-w-[48px]">
|
||||
<svg class="w-5 h-5 text-white/60" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 3L4 9v12h5v-7h6v7h5V9l-8-6z" />
|
||||
</svg>
|
||||
<span class="text-[9px] text-white/60 mt-0.5">Home</span>
|
||||
</button>
|
||||
|
||||
<!-- Shorts (active) -->
|
||||
<button class="flex flex-col items-center justify-center py-1 min-w-[48px]">
|
||||
<svg class="w-5 h-5 text-white" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path
|
||||
d="M10 14.65v-5.3L15 12l-5 2.65zm7.77-4.33c-.77-.32-1.2-.5-1.2-.5L18 9.06c1.84-.96 2.53-3.23 1.56-5.06s-3.24-2.53-5.07-1.56L6 6.94c-1.29.68-2.07 2.04-2 3.49.07 1.42.93 2.67 2.22 3.25.03.01 1.2.5 1.2.5L6 14.93c-1.83.97-2.53 3.24-1.56 5.07.97 1.83 3.24 2.53 5.07 1.56l8.5-4.5c1.29-.68 2.06-2.04 1.99-3.49-.07-1.42-.94-2.68-2.23-3.25z" />
|
||||
</svg>
|
||||
<span class="text-[9px] text-white mt-0.5">Shorts</span>
|
||||
</button>
|
||||
|
||||
<!-- Create -->
|
||||
<button class="flex items-center justify-center">
|
||||
<div class="w-10 h-7 bg-white rounded-full flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-black" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<textarea
|
||||
v-else
|
||||
:value="content"
|
||||
@input="emit('update:content', ($event.target as HTMLTextAreaElement).value)"
|
||||
class="w-full min-h-[40px] max-h-[80px] bg-transparent border-0 p-0 text-sm text-white resize-none focus:outline-none focus:ring-0 placeholder:text-gray-400"
|
||||
placeholder="Add a title..."
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- Subscriptions -->
|
||||
<button class="flex flex-col items-center justify-center py-1 min-w-[48px]">
|
||||
<svg class="w-5 h-5 text-white/60" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path
|
||||
d="M4 6h16v2H4zm2-4h12v2H6zm14 8H4c-1.1 0-2 .9-2 2v8c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2v-8c0-1.1-.9-2-2-2zm-8 12.5c-2.49 0-4.5-2.01-4.5-4.5s2.01-4.5 4.5-4.5 4.5 2.01 4.5 4.5-2.01 4.5-4.5 4.5zm0-6.5l-2 2.5h4l-2-2.5z" />
|
||||
</svg>
|
||||
<span class="text-[9px] text-white/60 mt-0.5">Subscriptions</span>
|
||||
</button>
|
||||
|
||||
<!-- Library -->
|
||||
<button class="flex flex-col items-center justify-center py-1 min-w-[48px]">
|
||||
<svg class="w-5 h-5 text-white/60" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path
|
||||
d="M4 6H2v14c0 1.1.9 2 2 2h14v-2H4V6zm16-4H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-8 12.5v-9l6 4.5-6 4.5z" />
|
||||
</svg>
|
||||
<span class="text-[9px] text-white/60 mt-0.5">Library</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Shorts badge -->
|
||||
<div class="absolute top-4 left-3 flex items-center gap-2">
|
||||
<div class="bg-[#ff0000] text-white text-xs font-bold px-2 py-1 rounded">
|
||||
Shorts
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer with char count (only in edit mode) -->
|
||||
<div v-if="!props.readonly" class="absolute top-4 right-3 flex items-center gap-2">
|
||||
<span
|
||||
class="text-xs px-2 py-1 rounded-full"
|
||||
:class="isValid ? 'bg-green-500/30 text-green-300' : 'bg-red-500/30 text-red-300'"
|
||||
>
|
||||
{{ validationMessage }}
|
||||
</span>
|
||||
<label v-if="media.length > 0" class="cursor-pointer p-2 rounded-full bg-black/30 hover:bg-black/50 transition-colors">
|
||||
<input
|
||||
type="file"
|
||||
accept="video/*,image/*"
|
||||
class="hidden"
|
||||
@change="emit('upload', $event)"
|
||||
:disabled="isUploading"
|
||||
/>
|
||||
<IconPhoto class="h-5 w-5 text-white" />
|
||||
</label>
|
||||
<!-- Video progress bar (above nav) -->
|
||||
<div v-if="media.length > 0" class="absolute bottom-[52px] left-0 right-0 h-[3px] bg-white/20 z-20">
|
||||
<div class="h-full w-2/5 bg-[#ff0000]"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
68
resources/js/composables/useDragAndDrop.ts
Normal file
68
resources/js/composables/useDragAndDrop.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { ref } from 'vue';
|
||||
|
||||
export interface DragAndDropOptions {
|
||||
acceptImages: boolean;
|
||||
acceptVideos: boolean;
|
||||
maxFiles: number;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function useDragAndDrop(options: DragAndDropOptions) {
|
||||
const isDragging = ref(false);
|
||||
|
||||
const isValidFileType = (file: File): boolean => {
|
||||
const isImage = file.type.startsWith('image/');
|
||||
const isVideo = file.type.startsWith('video/');
|
||||
|
||||
if (isImage && !options.acceptImages) {
|
||||
return false;
|
||||
}
|
||||
if (isVideo && !options.acceptVideos) {
|
||||
return false;
|
||||
}
|
||||
return isImage || isVideo;
|
||||
};
|
||||
|
||||
const handleDragOver = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
if (!options.disabled) {
|
||||
isDragging.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
isDragging.value = false;
|
||||
};
|
||||
|
||||
const handleDrop = (e: DragEvent, currentMediaCount: number, onUpload: (files: File[]) => void) => {
|
||||
e.preventDefault();
|
||||
isDragging.value = false;
|
||||
|
||||
if (options.disabled || !e.dataTransfer?.files) {
|
||||
return;
|
||||
}
|
||||
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
const validFiles = files.filter(isValidFileType);
|
||||
|
||||
if (validFiles.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Limit to remaining slots
|
||||
const remainingSlots = options.maxFiles - currentMediaCount;
|
||||
const filesToUpload = validFiles.slice(0, remainingSlots);
|
||||
|
||||
if (filesToUpload.length > 0) {
|
||||
onUpload(filesToUpload);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
isDragging,
|
||||
handleDragOver,
|
||||
handleDragLeave,
|
||||
handleDrop,
|
||||
};
|
||||
}
|
||||
301
resources/js/composables/useMediaManager.ts
Normal file
301
resources/js/composables/useMediaManager.ts
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
import axios from 'axios';
|
||||
import { ref, type Ref } from 'vue';
|
||||
|
||||
import {
|
||||
store as storeMedia,
|
||||
storeChunked as storeMediaChunked,
|
||||
destroy as destroyMedia,
|
||||
duplicate as duplicateMedia,
|
||||
reorder as reorderMedia,
|
||||
} from '@/actions/App/Http/Controllers/MediaController';
|
||||
import { getMediaRulesForContentType } from '@/composables/useMediaRules';
|
||||
import { uploadChunked, shouldUseChunkedUpload } from '@/utils/chunkedUpload';
|
||||
|
||||
export interface MediaItem {
|
||||
id: string;
|
||||
group_id: string | null;
|
||||
url: string;
|
||||
type: string;
|
||||
original_filename: string;
|
||||
}
|
||||
|
||||
interface PostPlatform {
|
||||
id: string;
|
||||
platform: string;
|
||||
content_type: string | null;
|
||||
media: MediaItem[];
|
||||
}
|
||||
|
||||
interface UseMediaManagerOptions {
|
||||
synced: Ref<boolean>;
|
||||
selectedPlatformIds: Ref<string[]>;
|
||||
platformContentTypes: Ref<Record<string, string>>;
|
||||
postPlatforms: Ref<PostPlatform[]>;
|
||||
}
|
||||
|
||||
export function useMediaManager(options: UseMediaManagerOptions) {
|
||||
const { synced, selectedPlatformIds, platformContentTypes, postPlatforms } = options;
|
||||
|
||||
// State
|
||||
const platformMedia = ref<Record<string, MediaItem[]>>(
|
||||
Object.fromEntries(postPlatforms.value.map(pp => [pp.id, pp.media || []]))
|
||||
);
|
||||
const isUploading = ref<Record<string, boolean>>({});
|
||||
|
||||
// Helper: check if platform's content type only allows single media
|
||||
const isSingleMediaContentType = (platformId: string): boolean => {
|
||||
const platform = postPlatforms.value.find(pp => pp.id === platformId);
|
||||
if (!platform) return false;
|
||||
|
||||
const contentType = platformContentTypes.value[platformId] || platform.content_type;
|
||||
if (!contentType) return false;
|
||||
|
||||
const rules = getMediaRulesForContentType(contentType);
|
||||
return rules.maxFiles === 1;
|
||||
};
|
||||
|
||||
// Helper: clear all media from a platform
|
||||
const clearPlatformMedia = async (platformId: string) => {
|
||||
const media = platformMedia.value[platformId] || [];
|
||||
|
||||
for (const m of media) {
|
||||
await axios.delete(destroyMedia.url({ modelId: platformId, media: m.id }));
|
||||
}
|
||||
|
||||
platformMedia.value[platformId] = [];
|
||||
};
|
||||
|
||||
// Upload files to a platform (with sync support)
|
||||
const upload = async (files: File[], postPlatformId: string) => {
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
// Get content type rules to check max files
|
||||
const platform = postPlatforms.value.find(pp => pp.id === postPlatformId);
|
||||
const contentType = platformContentTypes.value[postPlatformId] || platform?.content_type || '';
|
||||
const rules = getMediaRulesForContentType(contentType);
|
||||
|
||||
// Get other platforms to duplicate to (if synced)
|
||||
const otherPlatformIds = synced.value
|
||||
? selectedPlatformIds.value.filter(id => id !== postPlatformId)
|
||||
: [];
|
||||
|
||||
// Mark all as uploading
|
||||
isUploading.value[postPlatformId] = true;
|
||||
for (const id of otherPlatformIds) {
|
||||
isUploading.value[id] = true;
|
||||
}
|
||||
|
||||
// For single-media content types, clear existing media first
|
||||
if (rules.maxFiles === 1) {
|
||||
await clearPlatformMedia(postPlatformId);
|
||||
}
|
||||
|
||||
// Calculate how many files we can still add
|
||||
const currentMedia = platformMedia.value[postPlatformId] || [];
|
||||
const remainingSlots = rules.maxFiles - currentMedia.length;
|
||||
const filesToUpload = Array.from(files).slice(0, remainingSlots);
|
||||
|
||||
if (filesToUpload.length === 0) {
|
||||
// No slots available
|
||||
isUploading.value[postPlatformId] = false;
|
||||
for (const id of otherPlatformIds) {
|
||||
isUploading.value[id] = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
for (const file of filesToUpload) {
|
||||
try {
|
||||
let data;
|
||||
|
||||
// Use chunked upload for large files (> 10MB)
|
||||
if (shouldUseChunkedUpload(file)) {
|
||||
data = await uploadChunked({
|
||||
file,
|
||||
url: storeMediaChunked.url(),
|
||||
model: 'postPlatform',
|
||||
modelId: postPlatformId,
|
||||
collection: 'default',
|
||||
onProgress: (progress) => {
|
||||
console.log(`Upload progress: ${progress}%`);
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Regular upload for small files
|
||||
const formData = new FormData();
|
||||
formData.append('media', file);
|
||||
formData.append('model', 'postPlatform');
|
||||
formData.append('model_id', postPlatformId);
|
||||
|
||||
const response = await axios.post(storeMedia.url(), formData);
|
||||
data = response.data;
|
||||
}
|
||||
|
||||
// Add to current platform (use spread for reactivity)
|
||||
const currentMediaList = platformMedia.value[postPlatformId] || [];
|
||||
platformMedia.value[postPlatformId] = [...currentMediaList, data];
|
||||
|
||||
// If synced, duplicate to other platforms
|
||||
if (otherPlatformIds.length > 0) {
|
||||
const targets = otherPlatformIds.map(id => ({
|
||||
model: 'postPlatform',
|
||||
model_id: id,
|
||||
}));
|
||||
|
||||
const duplicateResponse = await axios.post(
|
||||
duplicateMedia.url({ media: data.id }),
|
||||
{ targets }
|
||||
);
|
||||
|
||||
const duplicates = duplicateResponse.data;
|
||||
for (const dup of duplicates) {
|
||||
// For single-media platforms, clear first
|
||||
if (isSingleMediaContentType(dup.mediable_id)) {
|
||||
await clearPlatformMedia(dup.mediable_id);
|
||||
}
|
||||
const existingMedia = platformMedia.value[dup.mediable_id] || [];
|
||||
platformMedia.value[dup.mediable_id] = [...existingMedia, dup];
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Upload failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Mark all as done
|
||||
isUploading.value[postPlatformId] = false;
|
||||
for (const id of otherPlatformIds) {
|
||||
isUploading.value[id] = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Remove media from a platform (with sync support)
|
||||
const remove = async (postPlatformId: string, mediaId: string) => {
|
||||
// Find the media to get its group_id for synced removal
|
||||
const mediaToRemove = platformMedia.value[postPlatformId]?.find(m => m.id === mediaId);
|
||||
|
||||
if (!mediaToRemove) return;
|
||||
|
||||
// Get target platforms - all selected if synced, otherwise just the current one
|
||||
const targetPlatformIds = synced.value ? selectedPlatformIds.value : [postPlatformId];
|
||||
|
||||
for (const targetId of targetPlatformIds) {
|
||||
// Find media with same group_id in this platform
|
||||
const mediaInPlatform = platformMedia.value[targetId]?.find(
|
||||
m => m.group_id === mediaToRemove.group_id
|
||||
);
|
||||
|
||||
if (mediaInPlatform) {
|
||||
// Remove from local state
|
||||
platformMedia.value[targetId] = platformMedia.value[targetId].filter(
|
||||
m => m.id !== mediaInPlatform.id
|
||||
);
|
||||
|
||||
// Delete from server
|
||||
await axios.delete(destroyMedia.url({ modelId: targetId, media: mediaInPlatform.id }));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Reorder media in a platform (with sync support)
|
||||
const reorder = async (postPlatformId: string, mediaIds: string[]) => {
|
||||
// Get the current platform's media to extract group_ids in new order
|
||||
const currentMedia = platformMedia.value[postPlatformId] || [];
|
||||
const reorderedMedia = mediaIds.map(id => currentMedia.find(m => m.id === id)).filter(Boolean) as MediaItem[];
|
||||
|
||||
// Get the group_ids in new order (for syncing to other platforms)
|
||||
const groupIdsInOrder = reorderedMedia.map(m => m.group_id);
|
||||
const firstGroupId = groupIdsInOrder[0];
|
||||
|
||||
// Get target platforms - all selected if synced, otherwise just the current one
|
||||
const targetPlatformIds = synced.value ? selectedPlatformIds.value : [postPlatformId];
|
||||
|
||||
// Collect all media items to reorder across all platforms
|
||||
const allMediaToReorder: { id: string; order: number }[] = [];
|
||||
|
||||
for (const targetId of targetPlatformIds) {
|
||||
const targetMedia = platformMedia.value[targetId] || [];
|
||||
const isSingleMedia = isSingleMediaContentType(targetId);
|
||||
|
||||
if (isSingleMedia && synced.value) {
|
||||
// For single-media platforms with sync enabled:
|
||||
// Check if current media matches the first group_id
|
||||
const currentSingleMedia = targetMedia[0];
|
||||
|
||||
if (currentSingleMedia && currentSingleMedia.group_id !== firstGroupId) {
|
||||
// Need to replace: delete current and duplicate the correct one
|
||||
// Find the source media (first of the new order from the active platform)
|
||||
const sourceMedia = reorderedMedia[0];
|
||||
|
||||
if (sourceMedia) {
|
||||
// Delete current media from this platform
|
||||
await axios.delete(destroyMedia.url({ modelId: targetId, media: currentSingleMedia.id }));
|
||||
|
||||
// Duplicate the correct media to this platform
|
||||
const duplicateResponse = await axios.post(
|
||||
duplicateMedia.url({ media: sourceMedia.id }),
|
||||
{ targets: [{ model: 'postPlatform', model_id: targetId }] }
|
||||
);
|
||||
|
||||
const duplicate = duplicateResponse.data[0];
|
||||
if (duplicate) {
|
||||
// Update local state with the new media
|
||||
platformMedia.value[targetId] = [{
|
||||
id: duplicate.id,
|
||||
group_id: duplicate.group_id,
|
||||
url: duplicate.url,
|
||||
type: duplicate.type,
|
||||
original_filename: duplicate.original_filename,
|
||||
}];
|
||||
|
||||
// Add to reorder payload
|
||||
allMediaToReorder.push({ id: duplicate.id, order: 0 });
|
||||
}
|
||||
}
|
||||
} else if (currentSingleMedia) {
|
||||
// Media is already correct, just update order
|
||||
allMediaToReorder.push({ id: currentSingleMedia.id, order: 0 });
|
||||
}
|
||||
} else {
|
||||
// For multi-media platforms: reorder based on group_id order
|
||||
const reorderedTargetMedia = groupIdsInOrder
|
||||
.map(groupId => targetMedia.find(m => m.group_id === groupId))
|
||||
.filter(Boolean) as MediaItem[];
|
||||
|
||||
// Update local state
|
||||
platformMedia.value[targetId] = reorderedTargetMedia;
|
||||
|
||||
// Add to API payload
|
||||
reorderedTargetMedia.forEach((m, index) => {
|
||||
allMediaToReorder.push({ id: m.id, order: index });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Send all reorders to API in one request
|
||||
if (allMediaToReorder.length > 0) {
|
||||
await axios.post(reorderMedia.url(), { media: allMediaToReorder });
|
||||
}
|
||||
};
|
||||
|
||||
// Get media for a specific platform
|
||||
const getMedia = (platformId: string): MediaItem[] => {
|
||||
return platformMedia.value[platformId] || [];
|
||||
};
|
||||
|
||||
// Check if a platform is uploading
|
||||
const isUploadingFor = (platformId: string): boolean => {
|
||||
return isUploading.value[platformId] || false;
|
||||
};
|
||||
|
||||
return {
|
||||
platformMedia,
|
||||
isUploading,
|
||||
upload,
|
||||
remove,
|
||||
reorder,
|
||||
getMedia,
|
||||
isUploadingFor,
|
||||
isSingleMediaContentType,
|
||||
};
|
||||
}
|
||||
118
resources/js/composables/useMediaRules.ts
Normal file
118
resources/js/composables/useMediaRules.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
import { computed, type Ref, type ComputedRef } from 'vue';
|
||||
|
||||
export interface MediaRules {
|
||||
maxFiles: number;
|
||||
minFiles?: number;
|
||||
acceptImages: boolean;
|
||||
acceptVideos: boolean;
|
||||
requiresMedia: boolean;
|
||||
}
|
||||
|
||||
const CONTENT_TYPE_RULES: Record<string, MediaRules> = {
|
||||
// Instagram
|
||||
instagram_feed: { maxFiles: 10, acceptImages: true, acceptVideos: true, requiresMedia: true },
|
||||
instagram_reel: { maxFiles: 1, acceptImages: false, acceptVideos: true, requiresMedia: true },
|
||||
instagram_story: { maxFiles: 1, acceptImages: true, acceptVideos: true, requiresMedia: true },
|
||||
|
||||
// Facebook
|
||||
facebook_post: { maxFiles: 10, acceptImages: true, acceptVideos: true, requiresMedia: false },
|
||||
facebook_reel: { maxFiles: 1, acceptImages: false, acceptVideos: true, requiresMedia: true },
|
||||
facebook_story: { maxFiles: 1, acceptImages: true, acceptVideos: true, requiresMedia: true },
|
||||
|
||||
// LinkedIn
|
||||
linkedin_post: { maxFiles: 1, acceptImages: true, acceptVideos: true, requiresMedia: false },
|
||||
linkedin_carousel: { maxFiles: 20, acceptImages: true, acceptVideos: false, requiresMedia: true },
|
||||
linkedin_page_post: { maxFiles: 1, acceptImages: true, acceptVideos: true, requiresMedia: false },
|
||||
linkedin_page_carousel: { maxFiles: 20, acceptImages: true, acceptVideos: false, requiresMedia: true },
|
||||
|
||||
// TikTok
|
||||
tiktok_video: { maxFiles: 1, acceptImages: false, acceptVideos: true, requiresMedia: true },
|
||||
|
||||
// YouTube
|
||||
youtube_short: { maxFiles: 1, acceptImages: false, acceptVideos: true, requiresMedia: true },
|
||||
|
||||
// Pinterest
|
||||
pinterest_pin: { maxFiles: 1, acceptImages: true, acceptVideos: false, requiresMedia: true },
|
||||
pinterest_video_pin: { maxFiles: 1, acceptImages: false, acceptVideos: true, requiresMedia: true },
|
||||
pinterest_carousel: { maxFiles: 5, minFiles: 2, acceptImages: true, acceptVideos: false, requiresMedia: true },
|
||||
|
||||
// X (Twitter)
|
||||
x_post: { maxFiles: 4, acceptImages: true, acceptVideos: true, requiresMedia: false },
|
||||
|
||||
// Threads
|
||||
threads_post: { maxFiles: 10, acceptImages: true, acceptVideos: true, requiresMedia: false },
|
||||
|
||||
// Bluesky
|
||||
bluesky_post: { maxFiles: 4, acceptImages: true, acceptVideos: true, requiresMedia: false },
|
||||
|
||||
// Mastodon
|
||||
mastodon_post: { maxFiles: 4, acceptImages: true, acceptVideos: true, requiresMedia: false },
|
||||
};
|
||||
|
||||
const DEFAULT_RULES: MediaRules = {
|
||||
maxFiles: 10,
|
||||
acceptImages: true,
|
||||
acceptVideos: true,
|
||||
requiresMedia: false,
|
||||
};
|
||||
|
||||
export function useMediaRules(contentType: Ref<string> | ComputedRef<string>) {
|
||||
const rules = computed<MediaRules>(() => {
|
||||
return CONTENT_TYPE_RULES[contentType.value] || DEFAULT_RULES;
|
||||
});
|
||||
|
||||
const acceptMimeTypes = computed<string>(() => {
|
||||
const types: string[] = [];
|
||||
if (rules.value.acceptImages) {
|
||||
types.push('image/*');
|
||||
}
|
||||
if (rules.value.acceptVideos) {
|
||||
types.push('video/*');
|
||||
}
|
||||
return types.join(',');
|
||||
});
|
||||
|
||||
const canAddMore = computed(() => {
|
||||
return (currentCount: number) => currentCount < rules.value.maxFiles;
|
||||
});
|
||||
|
||||
const isValidFileType = computed(() => {
|
||||
return (file: File): boolean => {
|
||||
const isImage = file.type.startsWith('image/');
|
||||
const isVideo = file.type.startsWith('video/');
|
||||
|
||||
if (isImage && !rules.value.acceptImages) {
|
||||
return false;
|
||||
}
|
||||
if (isVideo && !rules.value.acceptVideos) {
|
||||
return false;
|
||||
}
|
||||
return isImage || isVideo;
|
||||
};
|
||||
});
|
||||
|
||||
const getAcceptDescription = computed<string>(() => {
|
||||
if (rules.value.acceptImages && rules.value.acceptVideos) {
|
||||
return 'Images or videos';
|
||||
}
|
||||
if (rules.value.acceptImages) {
|
||||
return 'Images only';
|
||||
}
|
||||
if (rules.value.acceptVideos) {
|
||||
return 'Videos only';
|
||||
}
|
||||
return 'No media';
|
||||
});
|
||||
|
||||
return {
|
||||
rules,
|
||||
acceptMimeTypes,
|
||||
canAddMore,
|
||||
isValidFileType,
|
||||
getAcceptDescription,
|
||||
};
|
||||
}
|
||||
|
||||
export function getMediaRulesForContentType(contentType: string): MediaRules {
|
||||
return CONTENT_TYPE_RULES[contentType] || DEFAULT_RULES;
|
||||
}
|
||||
|
|
@ -39,26 +39,14 @@ const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribut
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
ref="formRef"
|
||||
:action="storeBluesky.url()"
|
||||
method="POST"
|
||||
@submit.prevent="submit"
|
||||
class="space-y-4"
|
||||
>
|
||||
<form ref="formRef" :action="storeBluesky.url()" method="POST" @submit.prevent="submit" class="space-y-4">
|
||||
<input type="hidden" name="_token" :value="csrfToken" />
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="identifier">Handle or Email</Label>
|
||||
<Input
|
||||
id="identifier"
|
||||
name="identifier"
|
||||
v-model="identifier"
|
||||
type="text"
|
||||
placeholder="yourhandle.bsky.social"
|
||||
:class="{ 'border-destructive': errors?.identifier }"
|
||||
required
|
||||
/>
|
||||
<Label for="identifier">Email</Label>
|
||||
<Input id="identifier" name="identifier" v-model="identifier" type="text"
|
||||
placeholder="yourhandle.bsky.social" :class="{ 'border-destructive': errors?.identifier }"
|
||||
required />
|
||||
<p v-if="errors?.identifier" class="text-sm text-destructive">
|
||||
{{ errors.identifier }}
|
||||
</p>
|
||||
|
|
@ -66,15 +54,9 @@ const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribut
|
|||
|
||||
<div class="space-y-2">
|
||||
<Label for="password">App Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
name="password"
|
||||
v-model="password"
|
||||
type="password"
|
||||
placeholder="xxxx-xxxx-xxxx-xxxx"
|
||||
:class="{ 'border-destructive': errors?.password }"
|
||||
required
|
||||
/>
|
||||
<Input id="password" name="password" v-model="password" type="password"
|
||||
placeholder="xxxx-xxxx-xxxx-xxxx" :class="{ 'border-destructive': errors?.password }"
|
||||
required />
|
||||
<p v-if="errors?.password" class="text-sm text-destructive">
|
||||
{{ errors.password }}
|
||||
</p>
|
||||
|
|
@ -83,18 +65,16 @@ const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribut
|
|||
<Alert>
|
||||
<IconInfoCircle class="h-4 w-4" />
|
||||
<AlertDescription class="inline">
|
||||
Use an <strong>App Password</strong> for security. Create one at <a href="https://bsky.app/settings/app-passwords" target="_blank" class="underline">bsky.app/settings</a>.
|
||||
Use an <strong>App Password</strong> for security. Create one at <a
|
||||
href="https://bsky.app/settings/app-passwords" target="_blank"
|
||||
class="underline">bsky.app/settings</a>.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
:disabled="isSubmitting"
|
||||
class="w-full"
|
||||
>
|
||||
<Button type="submit" :disabled="isSubmitting" class="w-full">
|
||||
{{ isSubmitting ? 'Connecting...' : 'Connect Bluesky' }}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</PopupLayout>
|
||||
</template>
|
||||
</template>
|
||||
|
|
@ -174,8 +174,12 @@ const getPlatformLogo = (platform: string): string => {
|
|||
'facebook': '/images/accounts/facebook.png',
|
||||
'instagram': '/images/accounts/instagram.png',
|
||||
'threads': '/images/accounts/threads.png',
|
||||
'pinterest': '/images/accounts/pinterest.png',
|
||||
'bluesky': '/images/accounts/bluesky.png',
|
||||
'mastodon': '/images/accounts/mastodon.png',
|
||||
|
||||
};
|
||||
return logos[platform] || '/images/accounts/default.png';
|
||||
return logos[platform];
|
||||
};
|
||||
|
||||
const getPostUrl = (post: Post): string => {
|
||||
|
|
@ -188,6 +192,7 @@ const formatTime = (scheduledAt: string): string => {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
<Head title="Calendar" />
|
||||
|
||||
<AppLayout :breadcrumbs="breadcrumbs">
|
||||
|
|
@ -228,24 +233,18 @@ const formatTime = (scheduledAt: string): string => {
|
|||
|
||||
<!-- Week View -->
|
||||
<div v-if="view === 'week'" class="flex-1 grid grid-cols-7 divide-x overflow-hidden">
|
||||
<div
|
||||
v-for="day in weekDays"
|
||||
:key="day.format('YYYY-MM-DD')"
|
||||
class="flex flex-col min-h-0"
|
||||
:class="{ 'bg-primary/5': isToday(day) }"
|
||||
>
|
||||
<div v-for="day in weekDays" :key="day.format('YYYY-MM-DD')" class="flex flex-col min-h-0"
|
||||
:class="{ 'bg-primary/5': isToday(day) }">
|
||||
<!-- Day Header -->
|
||||
<div class="flex flex-col items-center py-3 border-b bg-muted/30">
|
||||
<span class="text-xs font-medium text-muted-foreground uppercase">
|
||||
{{ day.format('ddd') }}
|
||||
</span>
|
||||
<span
|
||||
class="mt-1 flex items-center justify-center w-8 h-8 text-sm font-semibold rounded-full"
|
||||
<span class="mt-1 flex items-center justify-center w-8 h-8 text-sm font-semibold rounded-full"
|
||||
:class="{
|
||||
'bg-primary text-primary-foreground': isToday(day),
|
||||
'text-foreground': !isToday(day),
|
||||
}"
|
||||
>
|
||||
}">
|
||||
{{ day.format('D') }}
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -253,24 +252,15 @@ const formatTime = (scheduledAt: string): string => {
|
|||
<!-- Day Content -->
|
||||
<div class="flex-1 overflow-y-auto p-2 space-y-2">
|
||||
<!-- Add Post Button -->
|
||||
<Link
|
||||
:href="createPost.url({ query: { date: day.format('YYYY-MM-DD') } })"
|
||||
class="flex items-center justify-center p-2 rounded border border-dashed border-muted-foreground/30 text-muted-foreground hover:border-primary hover:text-primary hover:bg-primary/5 transition-colors"
|
||||
>
|
||||
<Link :href="createPost.url({ query: { date: day.format('YYYY-MM-DD') } })"
|
||||
class="flex items-center justify-center p-2 rounded border border-dashed border-muted-foreground/30 text-muted-foreground hover:border-primary hover:text-primary hover:bg-primary/5 transition-colors">
|
||||
<Plus class="h-4 w-4" />
|
||||
</Link>
|
||||
|
||||
<!-- Posts -->
|
||||
<Link
|
||||
v-for="post in getPostsForDay(day)"
|
||||
:key="post.id"
|
||||
:href="getPostUrl(post)"
|
||||
class="block"
|
||||
>
|
||||
<div
|
||||
class="p-2 rounded border text-sm transition-all hover:ring-2 hover:ring-primary hover:ring-offset-1"
|
||||
:class="getStatusColor(post.status)"
|
||||
>
|
||||
<Link v-for="post in getPostsForDay(day)" :key="post.id" :href="getPostUrl(post)" class="block">
|
||||
<div class="p-2 rounded border text-sm transition-all hover:ring-2 hover:ring-primary hover:ring-offset-1"
|
||||
:class="getStatusColor(post.status)">
|
||||
<!-- Time -->
|
||||
<div class="text-xs font-medium mb-1">
|
||||
{{ formatTime(post.scheduled_at) }}
|
||||
|
|
@ -278,17 +268,11 @@ const formatTime = (scheduledAt: string): string => {
|
|||
|
||||
<!-- Platforms -->
|
||||
<div class="flex -space-x-1 mb-1.5">
|
||||
<img
|
||||
v-for="pp in post.post_platforms.slice(0, 4)"
|
||||
:key="pp.id"
|
||||
:src="getPlatformLogo(pp.platform)"
|
||||
:alt="pp.platform"
|
||||
class="h-5 w-5 rounded-full ring-2 ring-white"
|
||||
/>
|
||||
<span
|
||||
v-if="post.post_platforms.length > 4"
|
||||
class="flex items-center justify-center h-5 w-5 rounded-full bg-muted text-[10px] font-medium ring-2 ring-white"
|
||||
>
|
||||
<img v-for="pp in post.post_platforms.slice(0, 4)" :key="pp.id"
|
||||
:src="getPlatformLogo(pp.platform)" :alt="pp.platform"
|
||||
class="h-5 w-5 rounded-full ring-2 ring-white" />
|
||||
<span v-if="post.post_platforms.length > 4"
|
||||
class="flex items-center justify-center h-5 w-5 rounded-full bg-muted text-[10px] font-medium ring-2 ring-white">
|
||||
+{{ post.post_platforms.length - 4 }}
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -307,88 +291,58 @@ const formatTime = (scheduledAt: string): string => {
|
|||
<div v-else class="flex-1 flex flex-col overflow-hidden border-l">
|
||||
<!-- Weekday Headers -->
|
||||
<div class="grid grid-cols-7 divide-x border-b bg-muted/30">
|
||||
<div
|
||||
v-for="day in ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']"
|
||||
:key="day"
|
||||
class="py-3 text-center text-xs font-medium text-muted-foreground uppercase"
|
||||
>
|
||||
<div v-for="day in ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']" :key="day"
|
||||
class="py-3 text-center text-xs font-medium text-muted-foreground uppercase">
|
||||
{{ day }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Calendar Grid -->
|
||||
<div
|
||||
class="flex-1 grid divide-y"
|
||||
:style="{ gridTemplateRows: `repeat(${calendarWeeks.length}, minmax(0, 1fr))` }"
|
||||
>
|
||||
<div
|
||||
v-for="(week, weekIndex) in calendarWeeks"
|
||||
:key="weekIndex"
|
||||
class="grid grid-cols-7 divide-x min-h-0"
|
||||
>
|
||||
<div
|
||||
v-for="day in week"
|
||||
:key="day.format('YYYY-MM-DD')"
|
||||
class="flex flex-col p-2 min-h-0 overflow-hidden group"
|
||||
:class="{
|
||||
<div class="flex-1 grid divide-y"
|
||||
:style="{ gridTemplateRows: `repeat(${calendarWeeks.length}, minmax(0, 1fr))` }">
|
||||
<div v-for="(week, weekIndex) in calendarWeeks" :key="weekIndex"
|
||||
class="grid grid-cols-7 divide-x min-h-0">
|
||||
<div v-for="day in week" :key="day.format('YYYY-MM-DD')"
|
||||
class="flex flex-col p-2 min-h-0 overflow-hidden group" :class="{
|
||||
'bg-primary/5': isToday(day),
|
||||
'bg-muted/30': !isCurrentMonth(day),
|
||||
}"
|
||||
>
|
||||
}">
|
||||
<!-- Day Header -->
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span
|
||||
class="flex items-center justify-center w-7 h-7 text-sm font-medium rounded-full"
|
||||
<span class="flex items-center justify-center w-7 h-7 text-sm font-medium rounded-full"
|
||||
:class="{
|
||||
'bg-primary text-primary-foreground': isToday(day),
|
||||
'text-muted-foreground': !isCurrentMonth(day),
|
||||
'text-foreground': isCurrentMonth(day) && !isToday(day),
|
||||
}"
|
||||
>
|
||||
}">
|
||||
{{ day.format('D') }}
|
||||
</span>
|
||||
<Link
|
||||
:href="createPost.url({ query: { date: day.format('YYYY-MM-DD') } })"
|
||||
class="opacity-0 group-hover:opacity-100 focus:opacity-100 p-1 rounded text-muted-foreground hover:text-primary hover:bg-primary/10 transition-all"
|
||||
>
|
||||
<Link :href="createPost.url({ query: { date: day.format('YYYY-MM-DD') } })"
|
||||
class="opacity-0 group-hover:opacity-100 focus:opacity-100 p-1 rounded text-muted-foreground hover:text-primary hover:bg-primary/10 transition-all">
|
||||
<Plus class="h-4 w-4" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<!-- Posts -->
|
||||
<div class="flex-1 overflow-y-auto space-y-1 min-h-0">
|
||||
<Link
|
||||
v-for="post in getPostsForDay(day).slice(0, 3)"
|
||||
:key="post.id"
|
||||
:href="getPostUrl(post)"
|
||||
class="block"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-between gap-1.5 px-2 py-1 rounded border text-xs transition-colors hover:brightness-95"
|
||||
:class="getStatusColor(post.status)"
|
||||
>
|
||||
<Link v-for="post in getPostsForDay(day).slice(0, 3)" :key="post.id"
|
||||
:href="getPostUrl(post)" class="block">
|
||||
<div class="flex items-center justify-between gap-1.5 px-2 py-1 rounded border text-xs transition-colors hover:brightness-95"
|
||||
:class="getStatusColor(post.status)">
|
||||
<span class="font-medium shrink-0">{{ formatTime(post.scheduled_at) }}</span>
|
||||
<div class="flex -space-x-1 shrink-0">
|
||||
<img
|
||||
v-for="pp in post.post_platforms.slice(0, post.post_platforms.length > 4 ? 3 : 4)"
|
||||
:key="pp.id"
|
||||
:src="getPlatformLogo(pp.platform)"
|
||||
:alt="pp.platform"
|
||||
class="h-4 w-4 rounded-full ring-1 ring-white"
|
||||
/>
|
||||
<span
|
||||
v-if="post.post_platforms.length > 4"
|
||||
class="flex items-center justify-center h-4 w-4 rounded-full bg-muted text-[9px] font-medium ring-1 ring-white"
|
||||
>
|
||||
<img v-for="pp in post.post_platforms.slice(0, post.post_platforms.length > 4 ? 3 : 4)"
|
||||
:key="pp.id" :src="getPlatformLogo(pp.platform)" :alt="pp.platform"
|
||||
class="h-4 w-4 rounded-full ring-1 ring-white" />
|
||||
<span v-if="post.post_platforms.length > 4"
|
||||
class="flex items-center justify-center h-4 w-4 rounded-full bg-muted text-[9px] font-medium ring-1 ring-white">
|
||||
+{{ post.post_platforms.length - 3 }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
<div
|
||||
v-if="getPostsForDay(day).length > 3"
|
||||
class="text-xs text-muted-foreground px-2 py-0.5"
|
||||
>
|
||||
<div v-if="getPostsForDay(day).length > 3"
|
||||
class="text-xs text-muted-foreground px-2 py-0.5">
|
||||
+{{ getPostsForDay(day).length - 3 }} more
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -398,4 +352,4 @@ const formatTime = (scheduledAt: string): string => {
|
|||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
</template>
|
||||
|
|
@ -1,16 +1,29 @@
|
|||
<script setup lang="ts">
|
||||
import { Head, router } from '@inertiajs/vue3';
|
||||
import { useEcho } from '@laravel/echo-vue';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
IconBrandBluesky,
|
||||
IconBrandFacebook,
|
||||
IconBrandInstagram,
|
||||
IconBrandLinkedin,
|
||||
IconBrandMastodon,
|
||||
IconBrandPinterest,
|
||||
IconBrandThreads,
|
||||
IconBrandTiktok,
|
||||
IconBrandX,
|
||||
IconBrandYoutube,
|
||||
} from '@tabler/icons-vue';
|
||||
import { Clock, AlertCircle, Trash2, Send, Link2, MoreHorizontal, Plus, CheckCircle, ExternalLink, Loader2 } from 'lucide-vue-next';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { computed, ref, watch, type Component } from 'vue';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
import { store as storeMedia, storeChunked as storeMediaChunked, destroy as destroyMedia, duplicate as duplicateMedia } from '@/actions/App/Http/Controllers/MediaController';
|
||||
import ConfirmDeleteModal from '@/components/ConfirmDeleteModal.vue';
|
||||
import DatePicker from '@/components/DatePicker.vue';
|
||||
import PhoneMockup from '@/components/PhoneMockup.vue';
|
||||
import PostForm from '@/components/posts/PostForm.vue';
|
||||
import { PlatformPreview } from '@/components/posts/previews';
|
||||
import { useMediaManager, type MediaItem } from '@/composables/useMediaManager';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import {
|
||||
AlertDialog,
|
||||
|
|
@ -30,17 +43,12 @@ import {
|
|||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import dayjs from '@/dayjs';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import { calendar } from '@/routes';
|
||||
import { destroy as destroyPost, update as updatePost } from '@/routes/posts';
|
||||
import { type BreadcrumbItemType } from '@/types';
|
||||
import { uploadChunked, shouldUseChunkedUpload } from '@/utils/chunkedUpload';
|
||||
|
||||
interface SocialAccount {
|
||||
id: string;
|
||||
|
|
@ -50,12 +58,6 @@ interface SocialAccount {
|
|||
avatar_url: string | null;
|
||||
}
|
||||
|
||||
interface MediaItem {
|
||||
id: string;
|
||||
url: string;
|
||||
type: string;
|
||||
original_filename: string;
|
||||
}
|
||||
|
||||
interface PostPlatform {
|
||||
id: string;
|
||||
|
|
@ -142,11 +144,6 @@ useEcho(
|
|||
// Check if post is in read-only mode (published, failed, publishing, partially_published)
|
||||
const isReadOnly = computed(() => ['published', 'failed', 'publishing', 'partially_published'].includes(post.value.status));
|
||||
|
||||
const breadcrumbs = computed<BreadcrumbItemType[]>(() => [
|
||||
{ title: 'Calendar', href: calendar.url() },
|
||||
{ title: isReadOnly.value ? 'View Post' : 'Edit Post', href: '#' },
|
||||
]);
|
||||
|
||||
// State
|
||||
const selectedPlatformIds = ref<string[]>(
|
||||
post.value.post_platforms.filter(pp => pp.enabled).map(pp => pp.id)
|
||||
|
|
@ -159,16 +156,28 @@ const globalContent = ref(
|
|||
const platformContents = ref<Record<string, string>>(
|
||||
Object.fromEntries(post.value.post_platforms.map(pp => [pp.id, pp.content]))
|
||||
);
|
||||
const platformMedia = ref<Record<string, MediaItem[]>>(
|
||||
Object.fromEntries(post.value.post_platforms.map(pp => [pp.id, pp.media || []]))
|
||||
);
|
||||
const platformContentTypes = ref<Record<string, string>>(
|
||||
Object.fromEntries(post.value.post_platforms.map(pp => [pp.id, pp.content_type || getDefaultContentType(pp.platform)]))
|
||||
);
|
||||
const platformMeta = ref<Record<string, Record<string, any>>>(
|
||||
Object.fromEntries(post.value.post_platforms.map(pp => [pp.id, pp.meta || {}]))
|
||||
);
|
||||
const isUploading = ref<Record<string, boolean>>({});
|
||||
|
||||
// Media manager composable
|
||||
const postPlatformsRef = computed(() => post.value.post_platforms);
|
||||
const {
|
||||
platformMedia,
|
||||
isUploading,
|
||||
upload: uploadMedia,
|
||||
remove: removeMedia,
|
||||
reorder: reorderMedia,
|
||||
} = useMediaManager({
|
||||
synced,
|
||||
selectedPlatformIds,
|
||||
platformContentTypes,
|
||||
postPlatforms: postPlatformsRef,
|
||||
});
|
||||
|
||||
const isSubmitting = ref(false);
|
||||
const deleteModal = ref<InstanceType<typeof ConfirmDeleteModal> | null>(null);
|
||||
const showEnableSyncDialog = ref(false);
|
||||
|
|
@ -188,18 +197,13 @@ watch(selectedPlatformIds, (newIds) => {
|
|||
// Convert UTC to workspace timezone for display
|
||||
const getLocalSchedule = () => {
|
||||
if (!post.value.scheduled_at) {
|
||||
return { date: '', time: '09:00' };
|
||||
return '';
|
||||
}
|
||||
const local = dayjs.utc(post.value.scheduled_at).tz(props.workspace.timezone);
|
||||
return {
|
||||
date: local.format('YYYY-MM-DD'),
|
||||
time: local.format('HH:mm'),
|
||||
};
|
||||
return local.format('YYYY-MM-DDTHH:mm:00');
|
||||
};
|
||||
|
||||
const { date: initialDate, time: initialTime } = getLocalSchedule();
|
||||
const scheduledDate = ref(initialDate);
|
||||
const scheduledTime = ref(initialTime);
|
||||
const scheduledDateTime = ref(getLocalSchedule());
|
||||
|
||||
const timezoneAbbr = computed(() => {
|
||||
return dayjs().tz(props.workspace.timezone).format('z');
|
||||
|
|
@ -240,6 +244,23 @@ const getPlatformLabel = (platform: string): string => {
|
|||
return labels[platform] || platform;
|
||||
};
|
||||
|
||||
const getPlatformIcon = (platform: string): Component => {
|
||||
const icons: Record<string, Component> = {
|
||||
'linkedin': IconBrandLinkedin,
|
||||
'linkedin-page': IconBrandLinkedin,
|
||||
'x': IconBrandX,
|
||||
'tiktok': IconBrandTiktok,
|
||||
'youtube': IconBrandYoutube,
|
||||
'facebook': IconBrandFacebook,
|
||||
'instagram': IconBrandInstagram,
|
||||
'threads': IconBrandThreads,
|
||||
'pinterest': IconBrandPinterest,
|
||||
'bluesky': IconBrandBluesky,
|
||||
'mastodon': IconBrandMastodon,
|
||||
};
|
||||
return icons[platform];
|
||||
};
|
||||
|
||||
const getStatusConfig = (status: string) => {
|
||||
const configs: Record<string, { label: string; color: string; icon: any }> = {
|
||||
'draft': { label: 'Draft', color: 'bg-gray-100 text-gray-800', icon: Clock },
|
||||
|
|
@ -373,6 +394,7 @@ const currentContentType = computed(() => {
|
|||
return platformContentTypes.value[activePlatform.value.id] || '';
|
||||
});
|
||||
|
||||
|
||||
// Set content type for a platform
|
||||
const setContentType = (platformId: string, value: string) => {
|
||||
platformContentTypes.value[platformId] = value;
|
||||
|
|
@ -444,9 +466,22 @@ const contentValidation = computed(() => {
|
|||
const hasMedia = media.length > 0;
|
||||
const meta = platformMeta.value[pp.id] || {};
|
||||
|
||||
// Check media type compatibility
|
||||
const imageCount = media.filter(m => m.type === 'image').length;
|
||||
const videoCount = media.filter(m => m.type === 'video').length;
|
||||
const hasUnsupportedImages = config.maxImages === 0 && imageCount > 0;
|
||||
const hasTooManyImages = imageCount > config.maxImages && config.maxImages > 0;
|
||||
const hasUnsupportedVideos = !config.allowedMediaTypes.includes('video') && videoCount > 0;
|
||||
|
||||
// Pinterest requires a board
|
||||
if (pp.platform === 'pinterest' && !meta.board_id) {
|
||||
results[pp.id] = { valid: false, message: 'Select a board', charCount, maxLength: config.maxContentLength };
|
||||
} else if (hasUnsupportedImages) {
|
||||
results[pp.id] = { valid: false, message: 'Images not supported', charCount, maxLength: config.maxContentLength };
|
||||
} else if (hasUnsupportedVideos) {
|
||||
results[pp.id] = { valid: false, message: 'Videos not supported', charCount, maxLength: config.maxContentLength };
|
||||
} else if (hasTooManyImages) {
|
||||
results[pp.id] = { valid: false, message: `Max ${config.maxImages} images`, charCount, maxLength: config.maxContentLength };
|
||||
} else if (!config.supportsTextOnly && !hasMedia) {
|
||||
results[pp.id] = { valid: false, message: 'Requires media', charCount, maxLength: config.maxContentLength };
|
||||
} else if (!hasContent && !hasMedia) {
|
||||
|
|
@ -490,7 +525,7 @@ const mediaValidation = computed(() => {
|
|||
|
||||
const canSubmit = computed(() => {
|
||||
if (selectedPlatformIds.value.length === 0) return false;
|
||||
if (!scheduledDate.value) return false;
|
||||
if (!scheduledDateTime.value) return false;
|
||||
|
||||
const selectedValidations = Object.entries(contentValidation.value)
|
||||
.filter(([id]) => selectedPlatformIds.value.includes(id));
|
||||
|
|
@ -510,139 +545,6 @@ const togglePlatform = (platformId: string) => {
|
|||
}
|
||||
};
|
||||
|
||||
// Platforms that only support single media (replace instead of add)
|
||||
const singleMediaPlatforms = ['tiktok', 'youtube', 'instagram'];
|
||||
|
||||
const isSingleMediaPlatform = (platformId: string): boolean => {
|
||||
const platform = post.value.post_platforms.find(pp => pp.id === platformId);
|
||||
return platform ? singleMediaPlatforms.includes(platform.platform) : false;
|
||||
};
|
||||
|
||||
const clearPlatformMedia = async (platformId: string) => {
|
||||
const media = platformMedia.value[platformId] || [];
|
||||
|
||||
for (const m of media) {
|
||||
await axios.delete(destroyMedia.url({ modelId: platformId, media: m.id }));
|
||||
}
|
||||
|
||||
platformMedia.value[platformId] = [];
|
||||
};
|
||||
|
||||
const handleFileUpload = async (event: Event, postPlatformId: string) => {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const files = input.files;
|
||||
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
// Get other platforms to duplicate to (if synced)
|
||||
const otherPlatformIds = synced.value
|
||||
? selectedPlatformIds.value.filter(id => id !== postPlatformId)
|
||||
: [];
|
||||
|
||||
// Mark all as uploading
|
||||
isUploading.value[postPlatformId] = true;
|
||||
for (const id of otherPlatformIds) {
|
||||
isUploading.value[id] = true;
|
||||
}
|
||||
|
||||
// For single-media platforms, clear existing media first
|
||||
if (isSingleMediaPlatform(postPlatformId)) {
|
||||
await clearPlatformMedia(postPlatformId);
|
||||
}
|
||||
|
||||
for (const file of Array.from(files)) {
|
||||
try {
|
||||
let data;
|
||||
|
||||
// Use chunked upload for large files (> 10MB)
|
||||
if (shouldUseChunkedUpload(file)) {
|
||||
data = await uploadChunked({
|
||||
file,
|
||||
url: storeMediaChunked.url(),
|
||||
model: 'postPlatform',
|
||||
modelId: postPlatformId,
|
||||
collection: 'default',
|
||||
onProgress: (progress) => {
|
||||
console.log(`Upload progress: ${progress}%`);
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Regular upload for small files
|
||||
const formData = new FormData();
|
||||
formData.append('media', file);
|
||||
formData.append('model', 'postPlatform');
|
||||
formData.append('model_id', postPlatformId);
|
||||
|
||||
const response = await axios.post(storeMedia.url(), formData);
|
||||
data = response.data;
|
||||
}
|
||||
|
||||
// Add to current platform (use spread for reactivity)
|
||||
const currentMedia = platformMedia.value[postPlatformId] || [];
|
||||
platformMedia.value[postPlatformId] = [...currentMedia, data];
|
||||
|
||||
// 2. If synced, duplicate to other platforms
|
||||
if (otherPlatformIds.length > 0) {
|
||||
const targets = otherPlatformIds.map(id => ({
|
||||
model: 'postPlatform',
|
||||
model_id: id,
|
||||
}));
|
||||
|
||||
const duplicateResponse = await axios.post(
|
||||
duplicateMedia.url({ media: data.id }),
|
||||
{ targets }
|
||||
);
|
||||
|
||||
const duplicates = duplicateResponse.data;
|
||||
for (const dup of duplicates) {
|
||||
// For single-media platforms, clear first
|
||||
if (isSingleMediaPlatform(dup.mediable_id)) {
|
||||
await clearPlatformMedia(dup.mediable_id);
|
||||
}
|
||||
const existingMedia = platformMedia.value[dup.mediable_id] || [];
|
||||
platformMedia.value[dup.mediable_id] = [...existingMedia, dup];
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Upload failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Mark all as done
|
||||
isUploading.value[postPlatformId] = false;
|
||||
for (const id of otherPlatformIds) {
|
||||
isUploading.value[id] = false;
|
||||
}
|
||||
input.value = '';
|
||||
};
|
||||
|
||||
const removeMedia = async (postPlatformId: string, mediaId: string) => {
|
||||
// Find the media to get its filename for synced removal
|
||||
const mediaToRemove = platformMedia.value[postPlatformId]?.find(m => m.id === mediaId);
|
||||
|
||||
if (!mediaToRemove) return;
|
||||
|
||||
// Get target platforms - all selected if synced, otherwise just the current one
|
||||
const targetPlatformIds = synced.value ? selectedPlatformIds.value : [postPlatformId];
|
||||
|
||||
for (const targetId of targetPlatformIds) {
|
||||
// Find media with same filename in this platform
|
||||
const mediaInPlatform = platformMedia.value[targetId]?.find(
|
||||
m => m.original_filename === mediaToRemove.original_filename
|
||||
);
|
||||
|
||||
if (mediaInPlatform) {
|
||||
// Remove from local state
|
||||
platformMedia.value[targetId] = platformMedia.value[targetId].filter(
|
||||
m => m.id !== mediaInPlatform.id
|
||||
);
|
||||
|
||||
// Delete from server
|
||||
await axios.delete(destroyMedia.url({ modelId: targetId, media: mediaInPlatform.id }));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getSubmitData = () => {
|
||||
const platforms = post.value.post_platforms
|
||||
.filter(pp => selectedPlatformIds.value.includes(pp.id))
|
||||
|
|
@ -653,12 +555,7 @@ const getSubmitData = () => {
|
|||
meta: platformMeta.value[pp.id] || {},
|
||||
}));
|
||||
|
||||
// Combine date and time into ISO format
|
||||
const scheduled_at = scheduledDate.value
|
||||
? `${scheduledDate.value}T${scheduledTime.value}:00`
|
||||
: null;
|
||||
|
||||
return { platforms, scheduled_at };
|
||||
return { platforms, scheduled_at: scheduledDateTime.value || null };
|
||||
};
|
||||
|
||||
const save = () => {
|
||||
|
|
@ -709,67 +606,65 @@ const deletePost = () => {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
<Head :title="isReadOnly ? 'View Post' : 'Edit Post'" />
|
||||
|
||||
<AppLayout :breadcrumbs="breadcrumbs">
|
||||
<div class="flex flex-col h-[calc(100vh-4rem)]">
|
||||
<AppLayout>
|
||||
<div class="flex flex-col h-screen">
|
||||
<!-- Top Bar with Tabs and Actions -->
|
||||
<div class="flex items-center justify-between border-b px-4 py-2 bg-background">
|
||||
<div class="relative flex items-center justify-between border-b px-4 py-2 bg-background">
|
||||
<!-- Platform Tabs -->
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Status Badge (when read-only) -->
|
||||
<Badge v-if="isReadOnly" :class="getStatusConfig(post.status).color" class="mr-2">
|
||||
<component :is="getStatusConfig(post.status).icon" class="mr-1 h-3 w-3" :class="{ 'animate-spin': post.status === 'publishing' }" />
|
||||
<component :is="getStatusConfig(post.status).icon" class="mr-1 h-3 w-3"
|
||||
:class="{ 'animate-spin': post.status === 'publishing' }" />
|
||||
{{ getStatusConfig(post.status).label }}
|
||||
</Badge>
|
||||
|
||||
<Tabs v-model="activeTabId" v-if="selectedPlatforms.length > 0">
|
||||
<TabsList class="h-auto p-1 gap-1">
|
||||
<TooltipProvider v-for="pp in selectedPlatforms" :key="pp.id">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<TabsTrigger :value="pp.id" class="relative px-2 py-1.5">
|
||||
<img
|
||||
:src="getPlatformLogo(pp.platform)"
|
||||
:alt="getPlatformLabel(pp.platform)"
|
||||
class="h-6 w-6 rounded"
|
||||
/>
|
||||
<!-- Status indicator (read-only) or Validation indicator (edit mode) -->
|
||||
<span
|
||||
v-if="isReadOnly"
|
||||
class="absolute -top-0.5 -right-0.5 h-2 w-2 rounded-full border border-background"
|
||||
:class="{
|
||||
'bg-green-500': pp.status === 'published',
|
||||
'bg-red-500': pp.status === 'failed',
|
||||
'bg-yellow-500 animate-pulse': pp.status === 'publishing',
|
||||
'bg-gray-400': !['published', 'failed', 'publishing'].includes(pp.status)
|
||||
}"
|
||||
/>
|
||||
<span
|
||||
v-else-if="contentValidation[pp.id]"
|
||||
class="absolute -top-0.5 -right-0.5 h-2 w-2 rounded-full border border-background"
|
||||
:class="contentValidation[pp.id].valid ? 'bg-green-500' : 'bg-red-500'"
|
||||
/>
|
||||
</TabsTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{{ pp.social_account.display_name }}</p>
|
||||
<p v-if="isReadOnly" class="text-xs text-muted-foreground">{{ getStatusConfig(pp.status).label }}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<div v-if="selectedPlatforms.length > 0"
|
||||
class="bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]">
|
||||
<TooltipProvider>
|
||||
<Tooltip v-for="pp in selectedPlatforms" :key="pp.id">
|
||||
<TooltipTrigger asChild>
|
||||
<button type="button" @click="activeTabId = pp.id" :class="[
|
||||
'relative inline-flex h-[calc(100%-1px)] items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring',
|
||||
activeTabId === pp.id
|
||||
? 'bg-background text-foreground shadow-sm dark:border-input dark:bg-input/30'
|
||||
: 'text-foreground dark:text-muted-foreground'
|
||||
]">
|
||||
<component :is="getPlatformIcon(pp.platform)"
|
||||
class="h-5 w-5 text-neutral-500 rounded-full" />
|
||||
<!-- Status indicator (read-only) or Validation indicator (edit mode) -->
|
||||
<span v-if="isReadOnly"
|
||||
class="absolute -top-0.5 -right-0.5 h-2 w-2 rounded-full border border-background"
|
||||
:class="{
|
||||
'bg-green-500': pp.status === 'published',
|
||||
'bg-red-500': pp.status === 'failed',
|
||||
'bg-yellow-500 animate-pulse': pp.status === 'publishing',
|
||||
'bg-gray-400': !['published', 'failed', 'publishing'].includes(pp.status)
|
||||
}" />
|
||||
<span v-else-if="contentValidation[pp.id]"
|
||||
class="absolute -top-0.5 -right-0.5 h-2 w-2 rounded-full border border-background"
|
||||
:class="contentValidation[pp.id].valid ? 'bg-green-500' : 'bg-red-500'" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{{ pp.social_account.display_name }}</p>
|
||||
<p v-if="isReadOnly" class="text-xs text-muted-foreground">
|
||||
{{ getStatusConfig(pp.status).label }}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
|
||||
<!-- Platforms Menu Button (only in edit mode) -->
|
||||
<TooltipProvider v-if="!isReadOnly">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
@click="showPlatformsDialog = true"
|
||||
class="p-2 rounded-lg hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<button type="button" @click="showPlatformsDialog = true"
|
||||
class="p-2 rounded-lg hover:bg-muted/50 transition-colors">
|
||||
<MoreHorizontal class="h-5 w-5 text-muted-foreground" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
|
|
@ -778,34 +673,32 @@ const deletePost = () => {
|
|||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
|
||||
<template v-if="!isReadOnly">
|
||||
<span class="mx-2 text-muted-foreground">|</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@click="deletePost"
|
||||
class="p-2 rounded-lg hover:bg-muted/50 transition-colors text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 class="h-5 w-5" />
|
||||
</button>
|
||||
</template>
|
||||
<!-- Sync Toggle (centered, only in edit mode with multiple platforms) -->
|
||||
<div v-if="!isReadOnly && selectedPlatformIds.length > 1"
|
||||
class="absolute left-1/2 -translate-x-1/2 flex items-center gap-2">
|
||||
<div @click.capture="handleSyncClick">
|
||||
<Switch id="sync-content" v-model="synced" />
|
||||
</div>
|
||||
<Label for="sync-content" class="text-sm cursor-pointer flex items-center gap-1.5">
|
||||
<Link2 class="h-4 w-4" />
|
||||
<span>Sync</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<component v-for="op in otherSelectedPlatforms.slice(0, 3)" :key="op.id"
|
||||
:is="getPlatformIcon(op.platform)" class="h-4 w-4 text-neutral-600" />
|
||||
<span v-if="otherSelectedPlatforms.length > 3" class="text-muted-foreground">
|
||||
+{{ otherSelectedPlatforms.length - 3 }}
|
||||
</span>
|
||||
</span>
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<!-- Schedule & Actions (only in edit mode) -->
|
||||
<div v-if="!isReadOnly" class="flex items-center gap-3">
|
||||
<!-- Date/Time Picker -->
|
||||
<!-- DateTime Picker -->
|
||||
<div class="flex items-center gap-2">
|
||||
<DatePicker
|
||||
name="scheduled_date"
|
||||
v-model="scheduledDate"
|
||||
class="w-[140px]"
|
||||
/>
|
||||
<Input
|
||||
type="time"
|
||||
v-model="scheduledTime"
|
||||
class="w-[100px] h-9"
|
||||
/>
|
||||
<DatePicker name="scheduled_datetime" v-model="scheduledDateTime" :show-time="true" />
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{ timezoneAbbr }}
|
||||
</span>
|
||||
|
|
@ -813,33 +706,24 @@ const deletePost = () => {
|
|||
|
||||
<span class="text-muted-foreground">|</span>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="selectedPlatformIds.length === 0 || isSubmitting"
|
||||
@click="save"
|
||||
>
|
||||
<Button type="button" variant="outline" size="icon-sm" @click="deletePost"
|
||||
class="text-muted-foreground hover:text-destructive">
|
||||
<Trash2 class="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Button type="button" variant="outline" size="sm"
|
||||
:disabled="selectedPlatformIds.length === 0 || isSubmitting" @click="save">
|
||||
{{ isSubmitting ? 'Saving...' : 'Save' }}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
:disabled="!canSubmit || isSubmitting"
|
||||
@click="submit('scheduled')"
|
||||
>
|
||||
<Button type="button" variant="secondary" size="sm" :disabled="!canSubmit || isSubmitting"
|
||||
@click="submit('scheduled')">
|
||||
<Clock class="mr-2 h-4 w-4" />
|
||||
Schedule
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
:disabled="!canSubmit || isSubmitting"
|
||||
@click="submit('publishing')"
|
||||
>
|
||||
<Button type="button" size="sm" :disabled="!canSubmit || isSubmitting"
|
||||
@click="submit('publishing')">
|
||||
<Send class="mr-2 h-4 w-4" />
|
||||
Publish
|
||||
</Button>
|
||||
|
|
@ -852,110 +736,120 @@ const deletePost = () => {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content Area -->
|
||||
<div class="flex-1 overflow-auto">
|
||||
<div v-if="activePlatform && selectedPlatformIds.length > 0" class="max-w-2xl mx-auto py-8 px-4">
|
||||
<!-- Sync Toggle (only in edit mode) -->
|
||||
<div v-if="!isReadOnly && selectedPlatformIds.length > 1" class="flex items-center justify-center gap-2 mb-6">
|
||||
<div @click.capture="handleSyncClick">
|
||||
<Switch id="sync-content" v-model="synced" />
|
||||
</div>
|
||||
<Label for="sync-content" class="text-sm cursor-pointer flex items-center gap-1.5">
|
||||
<Link2 class="h-4 w-4" />
|
||||
<span>Sync with</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<img
|
||||
v-for="op in otherSelectedPlatforms.slice(0, 3)"
|
||||
:key="op.id"
|
||||
:src="getPlatformLogo(op.platform)"
|
||||
class="h-4 w-4 rounded"
|
||||
/>
|
||||
<span v-if="otherSelectedPlatforms.length > 3" class="text-muted-foreground">
|
||||
+{{ otherSelectedPlatforms.length - 3 }}
|
||||
</span>
|
||||
</span>
|
||||
</Label>
|
||||
</div>
|
||||
<!-- Main Content Area - Split Layout -->
|
||||
<div class="flex-1 overflow-hidden">
|
||||
<div v-if="activePlatform && selectedPlatformIds.length > 0" class="h-full flex">
|
||||
<!-- Left Side: Form -->
|
||||
<div class="w-1/2 border-r overflow-y-auto">
|
||||
<div class="max-w-lg mx-auto py-8 px-6">
|
||||
<!-- Publication Result (read-only mode) -->
|
||||
<div v-if="isReadOnly" class="mb-6 space-y-3">
|
||||
<!-- Platform Status -->
|
||||
<div class="flex items-center justify-between p-4 rounded-lg border">
|
||||
<div class="flex items-center gap-3">
|
||||
<component :is="getPlatformIcon(activePlatform.platform)"
|
||||
class="h-8 w-8 text-neutral-600" />
|
||||
<div>
|
||||
<p class="font-medium">{{ activePlatform.social_account.display_name }}</p>
|
||||
<p class="text-sm text-muted-foreground">{{
|
||||
getPlatformLabel(activePlatform.platform) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Badge :class="getStatusConfig(activePlatform.status).color">
|
||||
<component :is="getStatusConfig(activePlatform.status).icon"
|
||||
class="mr-1 h-3 w-3"
|
||||
:class="{ 'animate-spin': activePlatform.status === 'publishing' }" />
|
||||
{{ getStatusConfig(activePlatform.status).label }}
|
||||
</Badge>
|
||||
<a v-if="activePlatform.platform_url" :href="activePlatform.platform_url"
|
||||
target="_blank"
|
||||
class="p-2 rounded-lg hover:bg-muted/50 transition-colors text-muted-foreground hover:text-foreground">
|
||||
<ExternalLink class="h-4 w-4" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Publication Result (read-only mode) -->
|
||||
<div v-if="isReadOnly" class="mb-6 space-y-3">
|
||||
<!-- Platform Status -->
|
||||
<div class="flex items-center justify-between p-4 rounded-lg border">
|
||||
<div class="flex items-center gap-3">
|
||||
<img
|
||||
:src="getPlatformLogo(activePlatform.platform)"
|
||||
:alt="getPlatformLabel(activePlatform.platform)"
|
||||
class="h-8 w-8 rounded"
|
||||
/>
|
||||
<div>
|
||||
<p class="font-medium">{{ activePlatform.social_account.display_name }}</p>
|
||||
<p class="text-sm text-muted-foreground">{{ getPlatformLabel(activePlatform.platform) }}</p>
|
||||
<!-- Error Message -->
|
||||
<Alert v-if="activePlatform.error_message" variant="destructive">
|
||||
<AlertCircle class="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
{{ activePlatform.error_message }}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<!-- Published Date -->
|
||||
<p v-if="activePlatform.published_at" class="text-sm text-muted-foreground text-center">
|
||||
Published {{ formatDateTime(activePlatform.published_at) }}
|
||||
</p>
|
||||
|
||||
<!-- Read-only content display -->
|
||||
<div class="mt-6 space-y-4">
|
||||
<!-- Media -->
|
||||
<div v-if="currentPlatformMedia.length > 0">
|
||||
<p class="text-sm font-medium mb-2">Media</p>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<div v-for="item in currentPlatformMedia" :key="item.id"
|
||||
class="aspect-square rounded-lg overflow-hidden bg-muted">
|
||||
<img v-if="item.type === 'image'" :src="item.url"
|
||||
:alt="item.original_filename" class="w-full h-full object-cover" />
|
||||
<video v-else :src="item.url"
|
||||
class="w-full h-full object-cover bg-black" muted />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Content -->
|
||||
<div>
|
||||
<p class="text-sm font-medium mb-2">Caption</p>
|
||||
<p class="text-sm text-muted-foreground whitespace-pre-wrap">
|
||||
{{ getContent(activePlatform.id) || 'No caption' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Badge :class="getStatusConfig(activePlatform.status).color">
|
||||
<component :is="getStatusConfig(activePlatform.status).icon" class="mr-1 h-3 w-3" :class="{ 'animate-spin': activePlatform.status === 'publishing' }" />
|
||||
{{ getStatusConfig(activePlatform.status).label }}
|
||||
</Badge>
|
||||
<a
|
||||
v-if="activePlatform.platform_url"
|
||||
:href="activePlatform.platform_url"
|
||||
target="_blank"
|
||||
class="p-2 rounded-lg hover:bg-muted/50 transition-colors text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ExternalLink class="h-4 w-4" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Edit Form -->
|
||||
<PostForm v-else :platform="activePlatform.platform"
|
||||
:content="getContent(activePlatform.id)" :media="currentPlatformMedia"
|
||||
:content-type="currentContentType"
|
||||
:content-type-options="getContentTypeOptions(activePlatform.platform)"
|
||||
:meta="platformMeta[activePlatform.id]"
|
||||
:platform-data="getPlatformData(activePlatform.platform)"
|
||||
:char-count="contentValidation[activePlatform.id]?.charCount || 0"
|
||||
:max-length="contentValidation[activePlatform.id]?.maxLength || 5000"
|
||||
:is-valid="contentValidation[activePlatform.id]?.valid ?? false"
|
||||
:validation-message="contentValidation[activePlatform.id]?.message || ''"
|
||||
:is-uploading="isUploading[activePlatform.id]" :disabled="isReadOnly"
|
||||
@update:content="setContent(activePlatform.id, $event)"
|
||||
@update:content-type="setContentType(activePlatform.id, $event)"
|
||||
@update:meta="platformMeta[activePlatform.id] = $event"
|
||||
@upload="uploadMedia($event, activePlatform.id)"
|
||||
@remove-media="removeMedia(activePlatform.id, $event)"
|
||||
@reorder-media="reorderMedia(activePlatform.id, $event)" />
|
||||
|
||||
<!-- Media Validation Errors -->
|
||||
<Alert v-if="!isReadOnly && mediaValidation.length > 0" variant="destructive" class="mt-4">
|
||||
<AlertCircle class="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
<ul class="list-disc list-inside">
|
||||
<li v-for="error in mediaValidation" :key="error">{{ error }}</li>
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
<Alert v-if="activePlatform.error_message" variant="destructive">
|
||||
<AlertCircle class="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
{{ activePlatform.error_message }}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<!-- Published Date -->
|
||||
<p v-if="activePlatform.published_at" class="text-sm text-muted-foreground text-center">
|
||||
Published {{ formatDateTime(activePlatform.published_at) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Platform Preview -->
|
||||
<PlatformPreview
|
||||
:key="activePlatform.id"
|
||||
:platform="activePlatform.platform"
|
||||
:social-account="activePlatform.social_account"
|
||||
:content="getContent(activePlatform.id)"
|
||||
:media="currentPlatformMedia"
|
||||
:content-type="currentContentType"
|
||||
:content-type-options="getContentTypeOptions(activePlatform.platform)"
|
||||
:meta="platformMeta[activePlatform.id]"
|
||||
:platform-data="getPlatformData(activePlatform.platform)"
|
||||
:char-count="contentValidation[activePlatform.id]?.charCount || 0"
|
||||
:max-length="contentValidation[activePlatform.id]?.maxLength || 5000"
|
||||
:is-valid="contentValidation[activePlatform.id]?.valid ?? false"
|
||||
:validation-message="contentValidation[activePlatform.id]?.message || ''"
|
||||
:is-uploading="isUploading[activePlatform.id]"
|
||||
:readonly="isReadOnly"
|
||||
@update:content="setContent(activePlatform.id, $event)"
|
||||
@update:content-type="setContentType(activePlatform.id, $event)"
|
||||
@update:meta="platformMeta[activePlatform.id] = $event"
|
||||
@upload="handleFileUpload($event, activePlatform.id)"
|
||||
@remove-media="removeMedia(activePlatform.id, $event)"
|
||||
/>
|
||||
|
||||
<!-- Media Validation Errors (only in edit mode) -->
|
||||
<Alert v-if="!isReadOnly && mediaValidation.length > 0" variant="destructive" class="mt-4">
|
||||
<AlertCircle class="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
<ul class="list-disc list-inside">
|
||||
<li v-for="error in mediaValidation" :key="error">{{ error }}</li>
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<!-- Right Side: Phone Preview -->
|
||||
<div class="w-1/2 bg-muted/30 overflow-y-auto">
|
||||
<div class="flex items-center justify-center min-h-full py-8 px-4">
|
||||
<PhoneMockup>
|
||||
<PlatformPreview :key="activePlatform.id" :platform="activePlatform.platform"
|
||||
:social-account="activePlatform.social_account"
|
||||
:content="getContent(activePlatform.id)" :media="currentPlatformMedia"
|
||||
:content-type="currentContentType" />
|
||||
</PhoneMockup>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
|
|
@ -966,14 +860,10 @@ const deletePost = () => {
|
|||
<h3 class="text-lg font-semibold mb-2">No platforms selected</h3>
|
||||
<p class="text-muted-foreground mb-4">Select at least one platform to create your post</p>
|
||||
<div class="flex flex-wrap justify-center gap-2">
|
||||
<button
|
||||
v-for="pp in post.post_platforms"
|
||||
:key="pp.id"
|
||||
type="button"
|
||||
<button v-for="pp in post.post_platforms" :key="pp.id" type="button"
|
||||
@click="togglePlatform(pp.id)"
|
||||
class="flex items-center gap-2 px-3 py-2 rounded-lg border hover:bg-muted transition-colors"
|
||||
>
|
||||
<img :src="getPlatformLogo(pp.platform)" class="h-5 w-5" />
|
||||
class="flex items-center gap-2 px-3 py-2 rounded-lg border hover:bg-muted transition-colors">
|
||||
<component :is="getPlatformIcon(pp.platform)" class="h-5 w-5 text-neutral-600" />
|
||||
<span class="text-sm">{{ pp.social_account.display_name }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -982,13 +872,9 @@ const deletePost = () => {
|
|||
</div>
|
||||
</AppLayout>
|
||||
|
||||
<ConfirmDeleteModal
|
||||
ref="deleteModal"
|
||||
title="Delete Post"
|
||||
description="Are you sure you want to delete this post? This action cannot be undone."
|
||||
action="Delete"
|
||||
cancel="Cancel"
|
||||
/>
|
||||
<ConfirmDeleteModal ref="deleteModal" title="Delete Post"
|
||||
description="Are you sure you want to delete this post? This action cannot be undone." action="Delete"
|
||||
cancel="Cancel" />
|
||||
|
||||
<!-- Enable Sync Confirmation Dialog -->
|
||||
<AlertDialog :open="showEnableSyncDialog" @update:open="showEnableSyncDialog = $event">
|
||||
|
|
@ -1000,7 +886,8 @@ const deletePost = () => {
|
|||
<AlertDialogDescription>
|
||||
If you enable syncing, you'll lose all edits made specifically to other platforms.
|
||||
<br /><br />
|
||||
Are you sure you want to sync with the {{ getPlatformLabel(firstSelectedPlatform?.platform || '') }} version?
|
||||
Are you sure you want to sync with the {{ getPlatformLabel(firstSelectedPlatform?.platform || '') }}
|
||||
version?
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
|
|
@ -1021,7 +908,8 @@ const deletePost = () => {
|
|||
Disable sync?
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Each platform will keep its current content, but future edits will only apply to the platform you're editing.
|
||||
Each platform will keep its current content, but future edits will only apply to the platform you're
|
||||
editing.
|
||||
<br /><br />
|
||||
You'll be able to customize the content for each platform individually.
|
||||
</AlertDialogDescription>
|
||||
|
|
@ -1045,27 +933,19 @@ const deletePost = () => {
|
|||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div class="grid grid-cols-2 gap-3 py-4">
|
||||
<div
|
||||
v-for="pp in post.post_platforms"
|
||||
:key="pp.id"
|
||||
class="flex items-center justify-between p-3 rounded-lg border"
|
||||
>
|
||||
<div v-for="pp in post.post_platforms" :key="pp.id"
|
||||
class="flex items-center justify-between p-3 rounded-lg border">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="relative shrink-0">
|
||||
<img
|
||||
v-if="pp.social_account.avatar_url"
|
||||
:src="pp.social_account.avatar_url"
|
||||
:alt="pp.social_account.display_name"
|
||||
class="h-10 w-10 rounded-full object-cover"
|
||||
/>
|
||||
<img v-if="pp.social_account.avatar_url" :src="pp.social_account.avatar_url"
|
||||
:alt="pp.social_account.display_name" class="h-10 w-10 rounded-full object-cover" />
|
||||
<div v-else class="h-10 w-10 rounded-full bg-muted flex items-center justify-center">
|
||||
<span class="text-sm font-medium">{{ pp.social_account.display_name?.charAt(0) }}</span>
|
||||
</div>
|
||||
<img
|
||||
:src="getPlatformLogo(pp.platform)"
|
||||
:alt="pp.platform"
|
||||
class="absolute -bottom-1 -right-1 h-5 w-5 rounded ring-2 ring-background"
|
||||
/>
|
||||
<div
|
||||
class="absolute -bottom-1 -right-1 h-5 w-5 rounded-full bg-background ring-2 ring-background flex items-center justify-center">
|
||||
<component :is="getPlatformIcon(pp.platform)" class="h-4 w-4 text-neutral-600" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="font-medium text-sm truncate">{{ pp.social_account.display_name }}</p>
|
||||
|
|
@ -1079,4 +959,4 @@ const deletePost = () => {
|
|||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
</template>
|
||||
|
|
@ -119,6 +119,7 @@
|
|||
// Media
|
||||
Route::post('medias', [MediaController::class, 'store'])->name('medias.store');
|
||||
Route::post('medias/chunked', [MediaController::class, 'storeChunked'])->name('medias.store-chunked');
|
||||
Route::post('medias/reorder', [MediaController::class, 'reorder'])->name('medias.reorder');
|
||||
Route::post('medias/{media}/duplicate', [MediaController::class, 'duplicate'])->name('medias.duplicate');
|
||||
Route::delete('medias/{modelId}/{media}', [MediaController::class, 'destroy'])->name('medias.destroy');
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue