Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/base/avif-image-def.gts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { readFirstBytes } from '@cardstack/runtime-common';
import { ImageDef } from './image-file-def';
import ImageDef from './image-file-def';
import { type ByteStream, type SerializedFile } from './file-api';
import { extractAvifDimensions } from './avif-meta-extractor';

Expand Down
6 changes: 4 additions & 2 deletions packages/base/brand-guide.gts
Original file line number Diff line number Diff line change
Expand Up @@ -1081,8 +1081,10 @@ export default class BrandGuide extends DetailedStyleRef {

@field cardThumbnailURL = contains(StringField, {
computeVia: function (this: BrandGuide) {
return this.cardInfo?.cardThumbnailURL?.length
? this.cardInfo?.cardThumbnailURL
let thumbnailURL =
this.cardInfo?.cardThumbnail?.url ?? this.cardInfo?.cardThumbnailURL;
return thumbnailURL?.length
? thumbnailURL
: this.markUsage?.socialMediaProfileIcon;
},
});
Expand Down
226 changes: 224 additions & 2 deletions packages/base/card-api.gts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
CodeRef,
CommandContext,
Deferred,
byteStreamToUint8Array,
fields,
fieldSerializer,
fieldsUntracked,
Expand All @@ -31,6 +32,7 @@ import {
getSerializer,
humanReadable,
identifyCard,
inferContentType,
isBaseInstance,
isCardError,
isCardInstance as _isCardInstance,
Expand All @@ -51,6 +53,7 @@ import {
relativeTo,
SingleCardDocument,
uuidv4,
NumberSerializer,
type Format,
type Meta,
type CardFields,
Expand Down Expand Up @@ -102,7 +105,13 @@ import DefaultHeadTemplate from './default-templates/head';
import MissingTemplate from './default-templates/missing-template';
import FieldDefEditTemplate from './default-templates/field-edit';
import MarkdownTemplate from './default-templates/markdown';
import FileDefEditTemplate from './default-templates/file-def-edit';
import ImageDefAtomTemplate from './default-templates/image-def-atom';
import ImageDefEmbeddedTemplate from './default-templates/image-def-embedded';
import ImageDefFittedTemplate from './default-templates/image-def-fitted';
import ImageDefIsolatedTemplate from './default-templates/image-def-isolated';
import CaptionsIcon from '@cardstack/boxel-icons/captions';
import FileIcon from '@cardstack/boxel-icons/file';
import LetterCaseIcon from '@cardstack/boxel-icons/letter-case';
import MarkdownIcon from '@cardstack/boxel-icons/align-box-left-middle';
import RectangleEllipsisIcon from '@cardstack/boxel-icons/rectangle-ellipsis';
Expand All @@ -111,9 +120,11 @@ import ThemeIcon from '@cardstack/boxel-icons/palette';
import ImportIcon from '@cardstack/boxel-icons/import';
import FilePencilIcon from '@cardstack/boxel-icons/file-pencil';
import WandIcon from '@cardstack/boxel-icons/wand';
import HashIcon from '@cardstack/boxel-icons/hash';
// normalizeEnumOptions used by enum moved to packages/base/enum.gts
import PatchThemeCommand from '@cardstack/boxel-host/commands/patch-theme';
import CopyAndEditCommand from '@cardstack/boxel-host/commands/copy-and-edit';
import { md5 } from 'super-fast-md5';

import {
callSerializeHook,
Expand Down Expand Up @@ -153,13 +164,14 @@ import {
setRealmContextOnField,
type NotLoadedValue,
} from './field-support';
import { TextInputValidator } from './text-input-validator';
import { type GetMenuItemParams, getDefaultCardMenuItems } from './menu-items';
import { getDefaultFileMenuItems } from './file-menu-items';
import {
LinkableDocument,
SingleFileMetaDocument,
} from '@cardstack/runtime-common/document-types';
import type { FileMetaResource } from '@cardstack/runtime-common';
import type { FileDef } from './file-api';

export const BULK_GENERATED_ITEM_COUNT = 3;

Expand Down Expand Up @@ -253,6 +265,17 @@ export type FieldFormats = {
};
type Setter = (value: any) => void;

export type SerializedFile<Extra extends object = {}> = {
sourceUrl: string;
url: string;
name: string;
contentType: string;
contentHash?: string;
contentSize?: number;
} & Extra;

export type ByteStream = ReadableStream<Uint8Array> | Uint8Array;

interface Options {
computeVia?: () => unknown;
description?: string;
Expand Down Expand Up @@ -2204,7 +2227,10 @@ export class BaseDef {
if (isNotLoadedValue(rawValue)) {
let normalizedId = rawValue.reference;
if (value[relativeTo]) {
normalizedId = resolveCardReference(normalizedId, value[relativeTo]);
normalizedId = resolveCardReference(
normalizedId,
value[relativeTo],
);
}
return [fieldName, { id: makeAbsoluteURL(rawValue.reference) }];
}
Expand Down Expand Up @@ -2489,10 +2515,206 @@ export class MarkdownField extends StringField {
};
}

export function deserializeForUI(value: string | number | null): number | null {
let validationError = NumberSerializer.validate(value);
if (validationError) {
return null;
}

return NumberSerializer.deserializeSync(value);
}

export function serializeForUI(val: number | null): string | undefined {
let serialized = NumberSerializer.serialize(val);
if (serialized != null) {
return String(serialized);
}
return undefined;
}

export class NumberField extends FieldDef {
static displayName = 'Number';
static icon = HashIcon;
static [primitive]: number;
static [fieldSerializer] = 'number';
static [useIndexBasedKey]: never;
static embedded = class View extends Component<typeof this> {
<template>{{@model}}</template>
};
static atom = this.embedded;

static edit = class Edit extends Component<typeof this> {
<template>
<BoxelInput
@value={{this.textInputValidator.asString}}
@onInput={{this.textInputValidator.onInput}}
@errorMessage={{this.textInputValidator.errorMessage}}
@state={{if this.textInputValidator.isInvalid 'invalid' 'none'}}
@disabled={{not @canEdit}}
/>
</template>

textInputValidator: TextInputValidator<number> = new TextInputValidator(
() => this.args.model,
(inputVal) => this.args.set(inputVal),
deserializeForUI,
serializeForUI,
NumberSerializer.validate,
);
};
}

export interface SerializedFileDef {
url?: string;
sourceUrl: string;
name?: string;
contentHash?: string;
contentSize?: number;
contentType?: string;
content?: string;
error?: string;
}

// Throw this error from extractAttributes when the file content doesn't match this FileDef's
// expectations so the extractor can fall back to a superclass/base FileDef.
export class FileContentMismatchError extends Error {
name = 'FileContentMismatchError';
}

export class FileDef extends BaseDef {
static displayName = 'File';
static isFileDef = true;
static icon = FileIcon;
[isSavedInstance] = true;

get [realmURL](): URL | undefined {
let realmURLString = getCardMeta(this, 'realmURL');
return realmURLString ? new URL(realmURLString) : undefined;
}

static assignInitialFieldValue(
instance: BaseDef,
fieldName: string,
value: any,
) {
if (fieldName === 'id') {
// Similar to CardDef, set 'id' directly in the deserialized cache
// to avoid triggering recomputes during instantiation
let deserialized = getDataBucket(instance);
deserialized.set('id', value);
} else {
super.assignInitialFieldValue(instance, fieldName, value);
}
}

@field id = contains(ReadOnlyField);
@field sourceUrl = contains(StringField);
@field url = contains(StringField);
@field name = contains(StringField);
@field contentType = contains(StringField);
@field contentHash = contains(StringField);
@field contentSize = contains(NumberField);

static embedded: BaseDefComponent = class View extends Component<
typeof this
> {
<template>{{@model.name}}</template>
};
static fitted = this.embedded;
static isolated = this.embedded;
static atom = this.embedded;
static edit: BaseDefComponent = FileDefEditTemplate;

static async extractAttributes(
url: string,
getStream: () => Promise<ByteStream>,
options: { contentHash?: string; contentSize?: number } = {},
): Promise<SerializedFile> {
let parsed = new URL(url);
let name = decodeURIComponent(
parsed.pathname.split('/').pop() ?? parsed.pathname,
);
let contentType = inferContentType(name);
let contentHash: string | undefined = options.contentHash;
let contentSize: number | undefined = options.contentSize;
if (!contentHash || contentSize === undefined) {
let bytes = await byteStreamToUint8Array(await getStream());
if (!contentHash) {
try {
contentHash = md5(bytes);
} catch {
contentHash = md5(new TextDecoder().decode(bytes));
}
}
if (contentSize === undefined) {
contentSize = bytes.byteLength;
}
}

return {
sourceUrl: url,
url,
name,
contentType,
contentHash,
contentSize,
};
}

serialize() {
return {
sourceUrl: this.sourceUrl,
url: this.url,
name: this.name,
contentType: this.contentType,
contentHash: this.contentHash,
contentSize: this.contentSize,
};
}

[getMenuItems](params: GetMenuItemParams): MenuItemOptions[] {
return getDefaultFileMenuItems(this, params);
}
}

export function createFileDef({
url,
sourceUrl,
name,
contentType,
contentHash,
contentSize,
}: SerializedFileDef) {
return new FileDef({
url,
sourceUrl,
name,
contentType,
contentHash,
contentSize,
});
}

export { getDefaultFileMenuItems } from './file-menu-items';

export class ImageDef extends FileDef {
static displayName = 'Image';
static acceptTypes = 'image/*';

@field width = contains(NumberField);
@field height = contains(NumberField);

static isolated: BaseDefComponent = ImageDefIsolatedTemplate;
static atom: BaseDefComponent = ImageDefAtomTemplate;
static embedded: BaseDefComponent = ImageDefEmbeddedTemplate;
static fitted: BaseDefComponent = ImageDefFittedTemplate;
}

export class CardInfoField extends FieldDef {
static displayName = 'Card Info';
@field name = contains(StringField);
@field summary = contains(StringField);
@field cardThumbnail = linksTo(() => ImageDef);
@field cardThumbnailURL = contains(MaybeBase64Field);
@field theme = linksTo(() => Theme);
@field notes = contains(MarkdownField);
Expand Down
10 changes: 10 additions & 0 deletions packages/base/default-templates/card-info.gts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import CaptionsIcon from '@cardstack/boxel-icons/captions';
import NameIcon from '@cardstack/boxel-icons/folder-pen';
import SummaryIcon from '@cardstack/boxel-icons/notepad-text';
import LinkIcon from '@cardstack/boxel-icons/link';
import ImageIcon from '@cardstack/boxel-icons/image';
import ThemeIcon from '@cardstack/boxel-icons/palette';

import type { CardOrFieldTypeIcon, CardDef, FieldsTypeFor } from '../card-api';
Expand Down Expand Up @@ -217,6 +218,14 @@ class CardInfoEditor extends GlimmerComponent<EditSignature> {
</FieldContainer>
{{#if this.isThumbnailEditorVisible}}
<div class='hidden-fields'>
<FieldContainer
class='card-info-field'
@label='Thumbnail Image'
@icon={{ImageIcon}}
data-test-field='cardInfo-thumbnail'
>
<@fields.cardInfo.cardThumbnail />
</FieldContainer>
<FieldContainer
class='card-info-field'
@label='Thumbnail URL'
Expand Down Expand Up @@ -332,6 +341,7 @@ class CardInfoEditor extends GlimmerComponent<EditSignature> {

private get showThumbnailPlaceholder() {
return (
!this.args.model?.cardInfo?.cardThumbnail &&
!this.args.model?.cardInfo?.cardThumbnailURL &&
this.args.model?.cardThumbnailURL
);
Expand Down
27 changes: 27 additions & 0 deletions packages/base/default-templates/file-def-edit.gts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import GlimmerComponent from '@glimmer/component';
import { concat } from '@ember/helper';
import type { FileDef } from '../card-api';

export default class FileDefEditTemplate extends GlimmerComponent<{
Args: {
model: FileDef;
};
}> {
<template>
<div class='filedef-edit-unavailable' data-test-filedef-edit-unavailable>
This file
{{if @model.id (concat ' (' @model.id ')')}}
is not editable via this interface. Replace it via file upload.
</div>
<style scoped>
.filedef-edit-unavailable {
background: var(--boxel-light);
border: 1px solid var(--boxel-200);
border-radius: var(--boxel-radius-sm);
color: var(--boxel-700);
font-size: var(--boxel-font-sm);
padding: var(--boxel-sp-md);
}
</style>
</template>
}
5 changes: 4 additions & 1 deletion packages/base/default-templates/head.gts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,10 @@ export default class DefaultHeadTemplate extends GlimmerComponent<{
}

get image(): string | undefined {
return this.args.model?.cardThumbnailURL;
return (
this.args.model?.cardInfo?.cardThumbnail?.url ??
this.args.model?.cardThumbnailURL
);
}

get themeIcon(): string | undefined {
Expand Down
Loading
Loading