diff --git a/README.md b/README.md index 5c97335..8fb71fd 100644 --- a/README.md +++ b/README.md @@ -209,7 +209,11 @@ Database migrations run automatically. Existing `sky_phone_mail_accounts` instal Camera and Gallery media is stored in `sky_phone_media`. Signed-out captures belong to the current IMEI; linking an iFruit account moves those rows into the account gallery so every linked phone sees them. Signing out hides cloud media without deleting it. Factory reset removes device-local media -and attempts to delete its remote FiveManage files, while account-owned media remains in the cloud. +and attempts to delete only Phone-created remote FiveManage files. Media imported from a website is +removed locally but never deleted at its source. Register import websites under +`Config.Media.Import.Websites` in `sky_phone/config/media.lua`; built-in adapters support FiveManage +files and version-1 JSON manifests. The Gallery import form accepts direct HTTPS image/video links +only when their hostname matches the selected website's `AllowedMediaHosts`. For a fresh manual database installation, import `sky_phone/sql/install.sql`. It contains the complete current table, key, index, collation, and foreign-key schema. Runtime migrations remain authoritative for upgrading an existing installation and must stay enabled. diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts index 611035a..8de3c3b 100644 --- a/frontend/src/stores/phone.ts +++ b/frontend/src/stores/phone.ts @@ -3432,13 +3432,45 @@ const defaultLocales: LocaleTree = { zoomOut: 'Zoom out', resetZoom: 'Reset zoom', filters: { all: 'All', photos: 'Photos', videos: 'Videos' }, + import: { + action: 'Import', + title: 'Import', + chooseSource: 'Choose a website', + linkTitle: 'Import from Link', + linkBody: + 'Paste a direct link to an image or video from this website.', + linkLabel: 'Image or video link', + linkPlaceholder: 'https://...', + linkCompleted: 'Media imported.', + loading: 'Loading media...', + emptyTitle: 'No Media', + emptyBody: 'This website has no media of this type.', + loadMore: 'Load More', + alreadyImported: 'Imported', + failed: 'Failed', + completed: 'Imported {imported} media.', + partial: 'Imported {imported} of {total} media.', + }, errors: { cancelled: 'The media action was cancelled.', capture_failed: 'Unable to capture the game view.', + invalid_import_request: 'The import request is invalid.', + invalid_import_media: 'This media item cannot be imported.', invalid_media_type: 'The media type is invalid.', invalid_upload: 'The upload could not be verified.', invalid_upload_token: 'The upload session is no longer valid.', missing_config: 'Gallery uploads are not configured.', + import_media_not_allowed: 'This media item is not allowed.', + import_media_too_large: 'This media item is too large.', + import_media_unavailable: 'This media item is no longer available.', + import_provider_failed: 'The website could not load its media.', + import_provider_unauthorized: 'The website credentials are invalid.', + import_source_not_found: 'This website is not registered.', + import_source_unavailable: 'This website is temporarily unavailable.', + invalid_import_url: 'Enter a valid HTTPS media link.', + import_url_not_allowed: 'This link is not from the selected website.', + import_url_unavailable: 'The linked media could not be reached.', + import_size_unavailable: 'The website did not provide the media size.', not_found: 'The media item no longer exists.', operation_in_progress: 'Another media operation is already in progress.', diff --git a/frontend/src/types/media.ts b/frontend/src/types/media.ts index 03587b0..e39ae62 100644 --- a/frontend/src/types/media.ts +++ b/frontend/src/types/media.ts @@ -8,6 +8,44 @@ export type PhoneMedia = { url: string } +export type MediaImportSource = { + id: string + label: string + mediaTypes: MediaType[] +} + +export type MediaImportSources = { + maxSelection: number + sources: MediaImportSource[] +} + +export type ExternalMedia = { + externalId: string + filename: string + imported: boolean + mediaType: MediaType + size: number + sourceId: string + url: string +} + +export type MediaImportPage = { + hasMore: boolean + items: ExternalMedia[] + page: number + total: number +} + +export type MediaImportFailure = { + error: string + externalId: string +} + +export type MediaImportResult = { + failed: MediaImportFailure[] + imported: PhoneMedia[] +} + export type UploadReady = { captureToken: string correlationId: string diff --git a/frontend/src/utils/media.test.ts b/frontend/src/utils/media.test.ts index d444aec..c2fd8de 100644 --- a/frontend/src/utils/media.test.ts +++ b/frontend/src/utils/media.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { filterMedia, + formatMediaSize, formatRecordingDuration, hasNextMediaPage, mediaErrorKey, @@ -44,8 +45,19 @@ describe('media utilities', () => { expect(formatRecordingDuration(3_725_000)).toBe('62:05') }) + it('formats imported media sizes with the active locale', () => { + expect(formatMediaSize(512, 'en')).toBe('512 B') + expect(formatMediaSize(1_572_864, 'en')).toBe('1.5 MB') + }) + it('maps unknown server failures to the localized default', () => { expect(mediaErrorKey('upload_timeout')).toBe('upload_timeout') + expect(mediaErrorKey('import_media_too_large')).toBe( + 'import_media_too_large', + ) + expect(mediaErrorKey('import_url_not_allowed')).toBe( + 'import_url_not_allowed', + ) expect(mediaErrorKey('private_provider_error')).toBe('request_failed') }) }) diff --git a/frontend/src/utils/media.ts b/frontend/src/utils/media.ts index 1bc21e3..d38bca8 100644 --- a/frontend/src/utils/media.ts +++ b/frontend/src/utils/media.ts @@ -37,14 +37,40 @@ export function formatRecordingDuration(elapsedMs: number): string { return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}` } +export function formatMediaSize(bytes: number, locale: string): string { + const safeBytes = Math.max(0, bytes) + if (safeBytes < 1024) return `${safeBytes} B` + const units = ['KB', 'MB', 'GB'] + let value = safeBytes / 1024 + let unit = units[0] + for (let index = 1; index < units.length && value >= 1024; index += 1) { + value /= 1024 + unit = units[index] + } + return `${new Intl.NumberFormat(locale, { maximumFractionDigits: 1 }).format(value)} ${unit}` +} + export function mediaErrorKey(error?: string): string { const known = new Set([ 'cancelled', 'capture_failed', 'invalid_media_type', + 'invalid_import_request', + 'invalid_import_media', + 'invalid_import_url', 'invalid_upload', 'invalid_upload_token', 'missing_config', + 'import_media_not_allowed', + 'import_media_too_large', + 'import_media_unavailable', + 'import_provider_failed', + 'import_provider_unauthorized', + 'import_source_not_found', + 'import_source_unavailable', + 'import_url_not_allowed', + 'import_url_unavailable', + 'import_size_unavailable', 'not_found', 'operation_in_progress', 'owner_changed', diff --git a/frontend/src/views/apps/GalleryApp.vue b/frontend/src/views/apps/GalleryApp.vue index c993d3f..9fd25ce 100644 --- a/frontend/src/views/apps/GalleryApp.vue +++ b/frontend/src/views/apps/GalleryApp.vue @@ -4,6 +4,9 @@ import { kButton, kDialog, kLink, + kList, + kListInput, + kListItem, kNavbar, kNavbarBackLink, kPage, @@ -12,14 +15,30 @@ import { kSegmentedButton, kToast, } from 'konsta/vue' -import { Play, RotateCcw, Share2, Trash2, ZoomIn, ZoomOut } from 'lucide-vue-next' +import { + ChevronRight, + Globe2, + Link2, + Play, + RotateCcw, + Share2, + Trash2, + ZoomIn, + ZoomOut, +} from 'lucide-vue-next' import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { useRoute, useRouter } from 'vue-router' import { useMessageMediaStore } from '@/stores/messageMedia' import { usePhoneStore } from '@/stores/phone' import { useEasyShareStore } from '@/stores/easyshare' -import type { DeleteResult, GalleryFilter, PhoneMedia } from '@/types/media' +import type { + DeleteResult, + GalleryFilter, + MediaImportSource, + MediaImportSources, + PhoneMedia, +} from '@/types/media' import { hasNextMediaPage, MEDIA_PAGE_SIZE, @@ -60,6 +79,12 @@ const fetching = ref(false) const hasMore = ref(true) const loadError = ref('') const selected = ref(null) +const importMode = ref<'form' | 'gallery' | 'sources'>('gallery') +const importSources = ref([]) +const importSource = ref(null) +const importUrl = ref('') +const importError = ref('') +const importing = ref(false) const deleteDialogOpened = ref(false) const cancelButtonColors = { fillBgIos: 'bg-[#8e8e93] active:bg-[#7a7a7f]', @@ -148,6 +173,71 @@ function showToast(text: string): void { }, 3000) } +async function loadImportSources(): Promise { + const response = await nuiCall('media:import:sources') + if (!response.success || !response.data) return + importSources.value = response.data.sources +} + +function selectImportSource(source: MediaImportSource): void { + importSource.value = source + importMode.value = 'form' + importUrl.value = '' + importError.value = '' +} + +function openImport(): void { + if (importSources.value.length === 1) { + selectImportSource(importSources.value[0]) + return + } + importMode.value = 'sources' +} + +function closeImport(): void { + importMode.value = 'gallery' + importSource.value = null + importUrl.value = '' + importError.value = '' +} + +function backFromImportForm(): void { + if (importSources.value.length > 1) { + importMode.value = 'sources' + importSource.value = null + importUrl.value = '' + importError.value = '' + return + } + closeImport() +} + +function updateImportUrl(event: Event): void { + importUrl.value = (event.target as HTMLInputElement).value + importError.value = '' +} + +async function commitUrlImport(): Promise { + const url = importUrl.value.trim() + if (!importSource.value || !url || importing.value) return + importing.value = true + importError.value = '' + const response = await nuiCall('media:import:url', { + sourceId: importSource.value.id, + url, + }) + importing.value = false + if (!response.success || !response.data) { + importError.value = phone.t( + `Apps.photos.errors.${mediaErrorKey(response.error)}`, + ) + return + } + media.value = mergeMedia(media.value, [response.data]) + closeImport() + showToast(phone.t('Apps.photos.import.linkCompleted')) +} + function formatDate(timestamp: number): string { return new Intl.DateTimeFormat(phone.lang, { dateStyle: 'medium', @@ -421,6 +511,7 @@ watch(hasMore, () => void nextTick().then(observeMore)) onMounted(() => { window.addEventListener('message', onMessage) void loadGallery() + void loadImportSources() }) onBeforeUnmount(() => { @@ -434,12 +525,105 @@ onBeforeUnmount(() => {