diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts
index 711ff4387..a227f8e5a 100644
--- a/src/app/app.routes.ts
+++ b/src/app/app.routes.ts
@@ -182,6 +182,11 @@ export const routes: Routes = [
import('./core/components/page-not-found/page-not-found.component').then((mod) => mod.PageNotFoundComponent),
data: { skipBreadcrumbs: true },
},
+ {
+ path: ':id/files/:fileGuid/preview',
+ loadComponent: () =>
+ import('./features/files/pages/file-preview/file-preview.component').then((m) => m.FilePreviewComponent),
+ },
{
path: 'spam-content',
loadComponent: () =>
diff --git a/src/app/features/files/pages/file-preview/file-preview.component.html b/src/app/features/files/pages/file-preview/file-preview.component.html
new file mode 100644
index 000000000..2548a7650
--- /dev/null
+++ b/src/app/features/files/pages/file-preview/file-preview.component.html
@@ -0,0 +1,31 @@
+
+
+
+
+ @if (safeLink) {
+
+ }
+ @if (isIframeLoading) {
+
+ }
+
+
+
+
+
diff --git a/src/app/features/files/pages/file-preview/file-preview.component.scss b/src/app/features/files/pages/file-preview/file-preview.component.scss
new file mode 100644
index 000000000..4bd4ee39e
--- /dev/null
+++ b/src/app/features/files/pages/file-preview/file-preview.component.scss
@@ -0,0 +1,9 @@
+.metadata {
+ border: 1px solid var(--grey-2);
+ border-radius: 0.75rem;
+}
+
+.full-image {
+ min-height: 100vh;
+ min-width: 100%;
+}
diff --git a/src/app/features/files/pages/file-preview/file-preview.component.spec.ts b/src/app/features/files/pages/file-preview/file-preview.component.spec.ts
new file mode 100644
index 000000000..2ef809426
--- /dev/null
+++ b/src/app/features/files/pages/file-preview/file-preview.component.spec.ts
@@ -0,0 +1,166 @@
+import { Store } from '@ngxs/store';
+
+import { MockProvider } from 'ng-mocks';
+
+import { Mock } from 'vitest';
+
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { ActivatedRoute, Router } from '@angular/router';
+
+import { FileKind } from '@osf/shared/enums/file-kind.enum';
+import { FileDetailsModel } from '@osf/shared/models/files/file.model';
+import { BaseNodeModel } from '@osf/shared/models/nodes/base-node.model';
+import { ViewOnlyLinkHelperService } from '@osf/shared/services/view-only-link-helper.service';
+
+import { provideOSFCore } from '@testing/osf.testing.provider';
+import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock';
+import { RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock';
+import {
+ BaseSetupOverrides,
+ mergeSignalOverrides,
+ provideMockStore,
+ SignalOverride,
+} from '@testing/providers/store-provider.mock';
+import { ViewOnlyLinkHelperMock, ViewOnlyLinkHelperMockType } from '@testing/providers/view-only-link-helper.mock';
+
+import { FilesSelectors, GetFile } from '../../store';
+
+import { FilePreviewComponent } from './file-preview.component';
+
+interface SetupOverrides extends BaseSetupOverrides {
+ hasViewOnlyParam?: boolean;
+ viewOnlyParam?: string | null;
+ renderLink?: string;
+}
+
+describe('FilePreviewComponent', () => {
+ let component: FilePreviewComponent;
+ let fixture: ComponentFixture;
+ let store: Store;
+ let mockRouter: RouterMockType;
+ let viewOnlyService: ViewOnlyLinkHelperMockType;
+
+ const encodedDownloadUrl = 'https://files.osf.io/v1/resources/abc/providers/osfstorage/file.txt';
+ const defaultRenderLink = `https://mfr.osf.io/render?url=${encodeURIComponent(encodedDownloadUrl)}`;
+
+ function buildFileDetailsModel(renderLink: string): FileDetailsModel {
+ return {
+ id: 'file-1',
+ guid: 'file-guid-1',
+ name: 'file.txt',
+ kind: FileKind.File,
+ path: '/file.txt',
+ size: 128,
+ materializedPath: '/file.txt',
+ dateModified: '2026-01-01T00:00:00.000Z',
+ dateCreated: '2026-01-01T00:00:00.000Z',
+ lastTouched: null,
+ tags: [],
+ currentVersion: 1,
+ showAsUnviewed: false,
+ extra: {
+ hashes: {
+ md5: 'md5',
+ sha256: 'sha256',
+ },
+ downloads: 1,
+ },
+ links: {
+ info: '',
+ move: '',
+ upload: '',
+ delete: '',
+ download: '',
+ render: renderLink,
+ html: '',
+ self: '',
+ },
+ target: {} as unknown as BaseNodeModel,
+ };
+ }
+
+ const defaultSignals: SignalOverride[] = [
+ { selector: FilesSelectors.isOpenedFileLoading, value: false },
+ { selector: FilesSelectors.getOpenedFile, value: buildFileDetailsModel(defaultRenderLink) },
+ ];
+
+ function setup(overrides: SetupOverrides = {}) {
+ const route = ActivatedRouteMockBuilder.create()
+ .withParams(overrides.routeParams ?? { fileGuid: 'file-1' })
+ .build();
+ mockRouter = RouterMockBuilder.create().withUrl('/files/file-1/preview').build();
+ viewOnlyService = ViewOnlyLinkHelperMock.simple(overrides.hasViewOnlyParam ?? false);
+ viewOnlyService.getViewOnlyParam = vi.fn().mockReturnValue(overrides.viewOnlyParam ?? null);
+
+ const signals = mergeSignalOverrides(defaultSignals, [
+ {
+ selector: FilesSelectors.getOpenedFile,
+ value: buildFileDetailsModel(overrides.renderLink ?? defaultRenderLink),
+ },
+ ...(overrides.selectorOverrides ?? []),
+ ]);
+
+ TestBed.configureTestingModule({
+ imports: [FilePreviewComponent],
+ providers: [
+ provideOSFCore(),
+ MockProvider(ActivatedRoute, route),
+ MockProvider(Router, mockRouter),
+ MockProvider(ViewOnlyLinkHelperService, viewOnlyService),
+ provideMockStore({ signals }),
+ ],
+ });
+
+ store = TestBed.inject(Store);
+ fixture = TestBed.createComponent(FilePreviewComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ }
+
+ it('should create', () => {
+ setup();
+
+ expect(component).toBeTruthy();
+ });
+
+ it('should dispatch get file action with route file guid on init', () => {
+ setup();
+
+ expect(store.dispatch).toHaveBeenCalledWith(new GetFile('file-1'));
+ });
+
+ it('should keep mfr url unchanged when render link has no nested url param', () => {
+ setup({ renderLink: 'https://mfr.osf.io/render' });
+ (store.dispatch as Mock).mockClear();
+
+ const result = component.getMfrUrlWithVersion('2');
+
+ expect(result).toBe('https://mfr.osf.io/render');
+ expect(store.dispatch).not.toHaveBeenCalled();
+ });
+
+ it('should append version param to nested download url', () => {
+ setup();
+
+ const result = component.getMfrUrlWithVersion('3');
+
+ expect(result).toContain('https://mfr.osf.io/render?');
+ expect(result).toContain(encodeURIComponent('version=3'));
+ });
+
+ it('should append view only param when present', () => {
+ setup({ hasViewOnlyParam: true, viewOnlyParam: 'view-token-1' });
+
+ const result = component.getMfrUrlWithVersion();
+
+ expect(result).toContain(encodeURIComponent('view_only=view-token-1'));
+ });
+
+ it('should return null for empty render link', () => {
+ setup({ renderLink: '' });
+
+ const result = component.getMfrUrlWithVersion();
+
+ expect(result).toBeNull();
+ });
+});
diff --git a/src/app/features/files/pages/file-preview/file-preview.component.ts b/src/app/features/files/pages/file-preview/file-preview.component.ts
new file mode 100644
index 000000000..ab9bbd904
--- /dev/null
+++ b/src/app/features/files/pages/file-preview/file-preview.component.ts
@@ -0,0 +1,79 @@
+import { createDispatchMap, select } from '@ngxs/store';
+
+import { TranslatePipe } from '@ngx-translate/core';
+
+import { switchMap } from 'rxjs';
+
+import { ChangeDetectionStrategy, Component, computed, DestroyRef, HostBinding, inject } from '@angular/core';
+import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
+import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
+import { ActivatedRoute, Router } from '@angular/router';
+
+import { FilesSelectors, GetFile } from '@osf/features/files/store';
+import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/loading-spinner.component';
+import { SubHeaderComponent } from '@osf/shared/components/sub-header/sub-header.component';
+import { ViewOnlyLinkHelperService } from '@osf/shared/services/view-only-link-helper.service';
+
+@Component({
+ selector: 'osf-draft-file-detail',
+ imports: [SubHeaderComponent, LoadingSpinnerComponent, TranslatePipe],
+ templateUrl: './file-preview.component.html',
+ styleUrl: './file-preview.component.scss',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class FilePreviewComponent {
+ @HostBinding('class') classes = 'flex flex-column flex-1 w-full h-full';
+
+ private readonly router = inject(Router);
+ private readonly route = inject(ActivatedRoute);
+ private readonly sanitizer = inject(DomSanitizer);
+ private readonly destroyRef = inject(DestroyRef);
+ private readonly viewOnlyService = inject(ViewOnlyLinkHelperService);
+
+ private readonly actions = createDispatchMap({ getFile: GetFile });
+
+ file = select(FilesSelectors.getOpenedFile);
+ isFileLoading = select(FilesSelectors.isOpenedFileLoading);
+
+ isIframeLoading = true;
+ safeLink: SafeResourceUrl | null = null;
+
+ hasViewOnly = computed(() => this.viewOnlyService.hasViewOnlyParam(this.router));
+
+ constructor() {
+ this.route.params
+ .pipe(
+ takeUntilDestroyed(this.destroyRef),
+ switchMap((params) => this.actions.getFile(params['fileGuid']))
+ )
+ .subscribe(() => this.getIframeLink(''));
+ }
+
+ getIframeLink(version: string) {
+ const url = this.getMfrUrlWithVersion(version);
+ if (url) {
+ this.safeLink = this.sanitizer.bypassSecurityTrustResourceUrl(url);
+ }
+ }
+
+ getMfrUrlWithVersion(version?: string): string | null {
+ const mfrUrl = this.file()?.links.render;
+ if (!mfrUrl) return null;
+ const mfrUrlObj = new URL(mfrUrl);
+ const encodedDownloadUrl = mfrUrlObj.searchParams.get('url');
+ if (!encodedDownloadUrl) return mfrUrl;
+
+ const downloadUrlObj = new URL(decodeURIComponent(encodedDownloadUrl));
+
+ if (version) downloadUrlObj.searchParams.set('version', version);
+
+ if (this.hasViewOnly()) {
+ const viewOnlyParam = this.viewOnlyService.getViewOnlyParam();
+ if (viewOnlyParam) downloadUrlObj.searchParams.set('view_only', viewOnlyParam);
+ }
+
+ mfrUrlObj.searchParams.set('url', downloadUrlObj.toString());
+
+ return mfrUrlObj.toString();
+ }
+}
diff --git a/src/app/features/registries/components/custom-step/custom-step.component.html b/src/app/features/registries/components/custom-step/custom-step.component.html
index 3424906dd..6bdc77c6e 100644
--- a/src/app/features/registries/components/custom-step/custom-step.component.html
+++ b/src/app/features/registries/components/custom-step/custom-step.component.html
@@ -179,6 +179,7 @@ {{ 'files.actions.uploadFile' | translate }}
[projectId]="projectId()"
[provider]="provider()"
(attachFile)="onAttachFile($event, q.responseKey!)"
+ (openFile)="onOpenFile($event)"
[filesViewOnly]="filesViewOnly()"
>
diff --git a/src/app/features/registries/components/custom-step/custom-step.component.ts b/src/app/features/registries/components/custom-step/custom-step.component.ts
index 357bc71b5..7b5f066e6 100644
--- a/src/app/features/registries/components/custom-step/custom-step.component.ts
+++ b/src/app/features/registries/components/custom-step/custom-step.component.ts
@@ -98,6 +98,7 @@ export class CustomStepComponent implements OnDestroy {
readonly INPUT_VALIDATION_MESSAGES = INPUT_VALIDATION_MESSAGES;
step = signal(this.route.snapshot.params['step']);
+ draftId = signal(this.route.snapshot.params['id']);
currentPage = computed(() => this.pages()[this.step() - 1]);
stepForm: FormGroup = this.fb.group({});
@@ -135,6 +136,13 @@ export class CustomStepComponent implements OnDestroy {
});
}
+ onOpenFile(file: FileModel): void {
+ if (this.draftId() && file.guid) {
+ const url = this.router.serializeUrl(this.router.createUrlTree([this.draftId(), 'files', file.guid, 'preview']));
+ window.open(url, '_blank');
+ }
+ }
+
removeFromAttachedFiles(file: AttachedFile, questionKey: string): void {
if (!this.attachedFiles[questionKey]) {
return;
diff --git a/src/app/features/registries/components/files-control/files-control.component.html b/src/app/features/registries/components/files-control/files-control.component.html
index 8d3350ae2..78c18d21c 100644
--- a/src/app/features/registries/components/files-control/files-control.component.html
+++ b/src/app/features/registries/components/files-control/files-control.component.html
@@ -52,7 +52,7 @@
[provider]="provider()"
[selectedFiles]="filesSelection"
(selectFile)="onFileTreeSelected($event)"
- (entryFileClicked)="selectFile($event)"
+ (entryFileClicked)="onEntryFileClicked($event)"
(uploadFilesConfirmed)="uploadFiles($event)"
(loadFiles)="onLoadFiles($event)"
(setCurrentFolder)="setCurrentFolder($event)"
diff --git a/src/app/features/registries/components/files-control/files-control.component.ts b/src/app/features/registries/components/files-control/files-control.component.ts
index 423a65d45..604141db5 100644
--- a/src/app/features/registries/components/files-control/files-control.component.ts
+++ b/src/app/features/registries/components/files-control/files-control.component.ts
@@ -54,6 +54,7 @@ export class FilesControlComponent {
provider = input.required();
filesViewOnly = input(false);
attachFile = output();
+ openFile = output();
private readonly filesService = inject(FilesService);
private readonly customDialogService = inject(CustomDialogService);
@@ -153,6 +154,11 @@ export class FilesControlComponent {
});
}
+ onEntryFileClicked(file: FileModel): void {
+ this.selectFile(file);
+ this.openFile.emit(file);
+ }
+
selectFile(file: FileModel): void {
if (this.filesViewOnly()) return;
this.attachFile.emit(file);
diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json
index 2887a045d..bcec6b3a1 100644
--- a/src/assets/i18n/en.json
+++ b/src/assets/i18n/en.json
@@ -615,7 +615,8 @@
"resourceLanguage": "Resource Language",
"resourceType": "Resource Type"
},
- "title": "File Metadata"
+ "title": "File Metadata",
+ "previewNotAvailable": "File or Registration metadata not available in preview mode."
},
"keywords": {
"title": "Keywords"