Signed-off-by: Denis Bykhov <bykhov.denis@gmail.com>
This commit is contained in:
Denis Bykhov
2026-04-12 12:32:43 +05:00
committed by GitHub
parent ac1553bd38
commit 963233b81d
29 changed files with 453 additions and 23 deletions
+1 -1
View File
@@ -1 +1 @@
"0.7.343"
"0.7.410"
+46
View File
@@ -394,6 +394,15 @@ export function createSystemType (
config: listConfig
})
builder.createDoc(view.class.Viewlet, core.space.Model, {
attachTo: type,
descriptor: card.viewlet.CardGridDescriptor,
baseQuery: {
isLatest: true
},
config: []
})
if (viewDefaults !== undefined) {
builder.mixin(type, card.class.MasterTag, card.mixin.CardViewDefaults, viewDefaults)
}
@@ -781,6 +790,43 @@ export function createModel (builder: Builder): void {
card.viewlet.CardRelationshipTable
)
builder.createDoc(
view.class.ViewletDescriptor,
core.space.Model,
{
label: card.string.Grid,
icon: card.icon.Grid,
component: card.component.CardGridView
},
card.viewlet.CardGridDescriptor
)
builder.createDoc(
view.class.Viewlet,
core.space.Model,
{
attachTo: card.class.Card,
descriptor: card.viewlet.CardGridDescriptor,
baseQuery: {
isLatest: true
},
config: [''],
configOptions: {
strict: true
},
viewOptions: {
groupBy: [],
orderBy: [
['modifiedOn', SortingOrder.Descending],
['rank', SortingOrder.Ascending],
['title', SortingOrder.Descending]
],
other: []
}
},
card.viewlet.CardGrid
)
builder.createDoc(
presentation.class.ComponentPointExtension,
core.space.Model,
+1 -1
View File
@@ -71,7 +71,7 @@ export const cardOperation: MigrateOperation = {
async upgrade (state: Map<string, Set<string>>, client: () => Promise<MigrationUpgradeClient>, mode): Promise<void> {
await tryUpgrade(mode, state, client, cardId, [
{
state: 'migrateViewlets-v6',
state: 'migrateViewlets-v7',
func: migrateViewlets
},
{
@@ -95,6 +95,7 @@
class:content-default={!embedded}
class:content-embedded={embedded}
class:flex-center={fit && !audio}
on:contextmenu
style:min-height={fit ? '0' : `${minHeight ?? 0}px`}
>
{#await srcRef then src}
@@ -42,7 +42,9 @@
<span class="emoji-{size} flex-row-center emoji" class:inline class:fitSize={inline}>
{#if value !== undefined}
{value}
<span class="value">
{value}
</span>
{:else}
{#await getBlobRef(asRef(icon)) then iconBlob}
<img src={iconBlob.src} srcset={iconBlob.srcset} alt="icon" />
@@ -103,8 +105,19 @@
font-size: 1.5rem;
}
.emoji-full {
width: inherit;
height: inherit;
width: 100%;
height: 100%;
container-type: size;
.value {
font-size: 70cqmin;
}
img {
width: 100%;
height: 100%;
object-fit: contain;
}
}
.emoji-smaller,
.emoji-x-small,
@@ -9,5 +9,5 @@
<div class="flex-presenter flex-gap-3">
<DatePresenter value={value.createdOn} mode={DateRangeMode.DATETIME} kind="list" icon={IconScribble} />
<PersonIdPresenter value={value.createdBy} shouldShowName={false} />
<PersonIdPresenter value={value.createdBy} withPadding={false} shouldShowName={false} />
</div>
+4
View File
@@ -76,4 +76,8 @@
<symbol id="lock" viewBox="0 0 16 16">
<path d="M4.69141 14.6338H11.3018C12.2178 14.6338 12.6689 14.1826 12.6689 13.1914V8.08496C12.6689 7.18945 12.293 6.72461 11.541 6.64941V4.96094C11.541 2.36328 9.81152 1.1123 7.99316 1.1123C6.18164 1.1123 4.45215 2.36328 4.45215 4.96094V6.67676C3.74805 6.78613 3.32422 7.2373 3.32422 8.08496V13.1914C3.32422 14.1826 3.77539 14.6338 4.69141 14.6338ZM5.75098 4.83105C5.75098 3.22461 6.76953 2.35645 7.99316 2.35645C9.2168 2.35645 10.2422 3.22461 10.2422 4.83105V6.64258L5.75098 6.64941V4.83105Z" />
</symbol>
<symbol id="grid" viewBox="0 0 20 20">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M7.5 12.5H3.75L3.75 16.25H7.5V12.5ZM7.5 3.75H3.75L3.75 7.5H7.5V3.75ZM16.25 12.5H12.5V16.25H16.25V12.5ZM16.25 3.75H12.5V7.5H16.25V3.75ZM3.75 2.5C3.05964 2.5 2.5 3.05964 2.5 3.75V7.5C2.5 8.19036 3.05964 8.75 3.75 8.75H7.5C8.19036 8.75 8.75 8.19036 8.75 7.5V3.75C8.75 3.05964 8.19036 2.5 7.5 2.5H3.75ZM3.75 11.25C3.05964 11.25 2.5 11.8096 2.5 12.5V16.25C2.5 16.9404 3.05964 17.5 3.75 17.5H7.5C8.19036 17.5 8.75 16.9404 8.75 16.25V12.5C8.75 11.8096 8.19036 11.25 7.5 11.25H3.75ZM11.25 12.5C11.25 11.8096 11.8096 11.25 12.5 11.25H16.25C16.9404 11.25 17.5 11.8096 17.5 12.5V16.25C17.5 16.9404 16.9404 17.5 16.25 17.5H12.5C11.8096 17.5 11.25 16.9404 11.25 16.25V12.5ZM12.5 2.5C11.8096 2.5 11.25 3.05964 11.25 3.75V7.5C11.25 8.19036 11.8096 8.75 12.5 8.75H16.25C16.9404 8.75 17.5 8.19036 17.5 7.5V3.75C17.5 3.05964 16.9404 2.5 16.25 2.5H12.5Z" />
</symbol>
</svg>

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 12 KiB

+2 -1
View File
@@ -82,6 +82,7 @@
"SectionUnlocked": "Sekce {section} odemknuta",
"LayoutAuto": "Auto",
"SingleColumn": "Jeden sloupec",
"TwoColumns": "Dva sloupce"
"TwoColumns": "Dva sloupce",
"Grid": "Mřížka"
}
}
+2 -1
View File
@@ -82,6 +82,7 @@
"SectionUnlocked": "Sektion {section} entsperrt",
"LayoutAuto": "Auto",
"SingleColumn": "Einspaltig",
"TwoColumns": "Zwei spaltig"
"TwoColumns": "Zwei spaltig",
"Grid": "Gitter"
}
}
+2 -1
View File
@@ -82,6 +82,7 @@
"SectionUnlocked": "Section {section} unlocked",
"LayoutAuto": "Auto",
"SingleColumn": "Single column",
"TwoColumns": "Two columns"
"TwoColumns": "Two columns",
"Grid": "Grid"
}
}
+2 -1
View File
@@ -82,6 +82,7 @@
"SectionUnlocked": "Sección {section} desbloqueada",
"LayoutAuto": "Auto",
"SingleColumn": "Una columna",
"TwoColumns": "Dos columnas"
"TwoColumns": "Dos columnas",
"Grid": "Cuadrícula"
}
}
+2 -1
View File
@@ -82,6 +82,7 @@
"SectionUnlocked": "Section {section} déverrouillée",
"LayoutAuto": "Auto",
"SingleColumn": "Une colonne",
"TwoColumns": "Deux colonnes"
"TwoColumns": "Deux colonnes",
"Grid": "Grille"
}
}
+2 -1
View File
@@ -82,6 +82,7 @@
"SectionUnlocked": "Sezione {section} sbloccata",
"LayoutAuto": "Auto",
"SingleColumn": "Una colonna",
"TwoColumns": "Due colonne"
"TwoColumns": "Due colonne",
"Grid": "Griglia"
}
}
+2 -1
View File
@@ -82,6 +82,7 @@
"SectionUnlocked": "セクション {section} アンロック済み",
"LayoutAuto": "自動",
"SingleColumn": "1列",
"TwoColumns": "2列"
"TwoColumns": "2列",
"Grid": "グリッド"
}
}
+2 -1
View File
@@ -82,6 +82,7 @@
"SectionUnlocked": "Seção {section} desbloqueada",
"LayoutAuto": "Automático",
"SingleColumn": "Uma coluna",
"TwoColumns": "Duas colunas"
"TwoColumns": "Duas colunas",
"Grid": "Grade"
}
}
+2 -1
View File
@@ -82,6 +82,7 @@
"SectionUnlocked": "Seção {section} desbloqueada",
"LayoutAuto": "Automático",
"SingleColumn": "Uma coluna",
"TwoColumns": "Duas colunas"
"TwoColumns": "Duas colunas",
"Grid": "Grade"
}
}
+2 -1
View File
@@ -82,6 +82,7 @@
"SectionUnlocked": "Секция {section} разблокирована",
"LayoutAuto": "Авто",
"SingleColumn": "Один столбец",
"TwoColumns": "Два столбца"
"TwoColumns": "Два столбца",
"Grid": "Сетка"
}
}
+2 -1
View File
@@ -82,6 +82,7 @@
"SectionUnlocked": "Bölüm {section} kilidi açıldı",
"LayoutAuto": "Otomatik",
"SingleColumn": "Tek sütun",
"TwoColumns": "İki sütun"
"TwoColumns": "İki sütun",
"Grid": "Izgara"
}
}
+2 -1
View File
@@ -82,6 +82,7 @@
"SectionUnlocked": "部分 {section} 已解锁",
"LayoutAuto": "自动",
"SingleColumn": "单列",
"TwoColumns": "两列"
"TwoColumns": "两列",
"Grid": "网格"
}
}
+2 -1
View File
@@ -30,5 +30,6 @@ loadMetadata(card.icon, {
Feed: `${icons}#feed`,
All: `${icons}#all`,
Duplicate: `${icons}#duplicate`,
Lock: `${icons}#lock`
Lock: `${icons}#lock`,
Grid: `${icons}#grid`
})
@@ -0,0 +1,199 @@
<!--
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import { type Card } from '@hcengineering/card'
import { type WithLookup } from '@hcengineering/core'
import { getClient, Image, remToPx } from '@hcengineering/presentation'
import { Button, IconMoreH } from '@hcengineering/ui'
import view from '@hcengineering/view'
import { openDoc, PersonIdPresenter, showMenu, TimestampPresenter } from '@hcengineering/view-resources'
import { createEventDispatcher } from 'svelte'
import card from '../plugin'
import CardIcon from './CardIcon.svelte'
import CardPresenter from './CardPresenter.svelte'
export let object: WithLookup<Card>
export let selected: boolean = false
const client = getClient()
const hierarchy = client.getHierarchy()
const dispatch = createEventDispatcher()
const size = remToPx(20)
let hovered = false
$: isFile = hierarchy.isDerived(object._class, card.types.File)
$: mainBlob = Object.values(object.blobs ?? {})[0]
$: isImage = mainBlob?.type?.startsWith('image/')
$: previewRef = mainBlob?.file
</script>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="card-container"
class:selected
class:hovered
draggable="false"
on:mouseover={() => dispatch('obj-focus', object)}
on:mouseenter={() => dispatch('obj-focus', object)}
on:focus={() => {}}
on:contextmenu={(evt) => {
showMenu(evt, { object })
}}
>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class="card"
on:click={() => {
void openDoc(hierarchy, object)
}}
>
<div class="card-content" class:is-file={isFile}>
{#if isFile && previewRef && isImage}
<Image
blob={previewRef}
alt={object.title}
width={size}
height={size}
blurhash={mainBlob?.metadata?.thumbnail?.blurhash}
responsive
fit={'cover'}
/>
{:else if isFile && mainBlob}
<div class="flex-center ext-icon">
{mainBlob.name.split('.').pop()?.substring(0, 4).toUpperCase()}
</div>
{:else}
<CardIcon value={object} size={'full'} />
{/if}
</div>
<div class="header flex-col">
<div class="flex-row-center flex-gap-2 h-8">
<div class="title overflow-label flex-grow font-medium-14">
<CardPresenter value={object} type={'text'} />
</div>
<div class="tools">
<Button
icon={IconMoreH}
kind="ghost"
size="medium"
showTooltip={{ label: view.string.MoreActions, direction: 'bottom' }}
on:click={(evt) => {
hovered = true
showMenu(evt, { object }, () => {
hovered = false
})
}}
/>
</div>
</div>
<div class="flex-between flex-gap-2 h-4 mt-2">
<PersonIdPresenter value={object.createdBy} withPadding={false} noUnderline avatarSize="tiny" />
<span class="flex-no-shrink caption-color">
<TimestampPresenter value={object.createdOn ?? object.modifiedOn} />
</span>
</div>
</div>
</div>
</div>
<style lang="scss">
.card-container {
position: relative;
border-radius: 0.75rem;
border: 1px solid var(--theme-divider-color);
background-color: var(--theme-kanban-card-bg-color);
overflow: hidden;
transition: all 0.2s ease-in-out;
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.05),
0 2px 4px -1px rgba(0, 0, 0, 0.03);
&:hover {
box-shadow:
0 10px 15px -3px rgba(0, 0, 0, 0.08),
0 4px 6px -2px rgba(0, 0, 0, 0.04);
border-color: var(--theme-border-color-dark);
transform: translateY(-2px);
}
&.selected {
outline: 2px solid var(--global-focus-BorderColor);
outline-offset: 2px;
background-color: var(--highlight-hover);
}
&:hover,
&.hovered {
.tools {
display: block;
}
}
}
.card {
cursor: pointer;
display: flex;
flex-direction: column;
height: 100%;
}
.tools {
display: none;
background: var(--theme-kanban-card-bg-color);
border-radius: 0.5rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.card-content {
display: flex;
justify-content: center;
align-items: center;
height: 11rem;
background: linear-gradient(180deg, var(--theme-border-color-light) 0%, var(--theme-divider-color) 100%);
position: relative;
&.is-file {
background: var(--theme-bg-color-alt);
}
}
.header {
border-top: 1px solid var(--theme-divider-color);
padding: 0.25rem 0.75rem 0.75rem 0.75rem;
background-color: var(--theme-kanban-card-bg-color);
}
.title {
color: var(--theme-text-color);
line-height: 1.25rem;
}
.ext-icon {
flex-shrink: 0;
width: 3rem;
height: 3rem;
font-weight: 600;
font-size: 0.75rem;
color: var(--primary-button-color);
background-color: var(--primary-button-default);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 0.5rem;
}
</style>
@@ -0,0 +1,97 @@
<!--
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import type { Class, Doc, DocumentQuery, FindOptions, Ref, WithLookup } from '@hcengineering/core'
import { type Card } from '@hcengineering/card'
import { ActionContext, createQuery, getClient } from '@hcengineering/presentation'
import { BuildModelKey, ViewOptions } from '@hcengineering/view'
import { ListSelectionProvider, SelectDirection, buildConfigLookup, focusStore } from '@hcengineering/view-resources'
import CardGridItem from './CardGridItem.svelte'
export let _class: Ref<Class<Card>>
export let query: DocumentQuery<Card>
export let config: Array<BuildModelKey | string>
export let options: FindOptions<Card> | undefined = undefined
export let viewOptions: ViewOptions
const client = getClient()
const hierarchy = client.getHierarchy()
const q = createQuery()
const listProvider = new ListSelectionProvider((offset: 1 | -1 | 0, of?: Doc, dir?: SelectDirection) => {
if (dir === 'vertical') {
let pos = objects.findIndex((p) => p._id === of?._id)
pos += offset
if (pos < 0) {
pos = 0
}
if (pos >= objects.length) {
pos = objects.length - 1
}
listProvider.updateFocus(objects[pos])
}
})
let objects: WithLookup<Card>[] = []
$: orderBy = viewOptions.orderBy
$: lookup = buildConfigLookup(hierarchy, _class, config, options?.lookup)
$: q.query(
_class,
query,
(result) => {
objects = result
},
{
...options,
sort: {
...(options != null ? options.sort : {}),
...(orderBy != null ? { [orderBy[0]]: orderBy[1] } : {})
},
lookup
}
)
$: listProvider.update(objects)
$: selection = listProvider.current($focusStore)
</script>
<ActionContext context={{ mode: 'browser' }} />
<div class="grid-container">
{#each objects as object, i}
{@const selected = selection === i}
<div class="grid-cell">
<CardGridItem
{object}
{selected}
on:obj-focus={(evt) => {
listProvider.updateFocus(evt.detail)
}}
/>
</div>
{/each}
</div>
<style lang="scss">
.grid-container {
display: grid;
margin: 0.5rem 1rem;
padding-bottom: 0.5rem;
grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr));
gap: 1rem;
}
</style>
@@ -14,7 +14,15 @@
-->
<script lang="ts">
import card, { Card } from '@hcengineering/card'
import { getClient, FilePreview } from '@hcengineering/presentation'
import { BlobType } from '@hcengineering/core'
import presentation, {
deleteFile,
FilePreview,
getClient,
getFileUrl,
IconDownload
} from '@hcengineering/presentation'
import { getEventPositionElement, IconDelete, Menu, showPopup, type Action } from '@hcengineering/ui'
import Description from './Description.svelte'
import FilePlaceholder from './FilePlaceholder.svelte'
@@ -27,6 +35,44 @@
const hierarchy = client.getHierarchy()
$: isFile = hierarchy.isDerived(doc._class, card.types.File)
async function showContextMenu (blob: BlobType, ev: MouseEvent) {
ev.preventDefault()
ev.stopPropagation()
const actions: Action[] = [
{
label: presentation.string.Download,
icon: IconDownload,
action: async () => {
const url = getFileUrl(blob.file, blob.name)
const a = document.createElement('a')
a.href = url
a.download = blob.name
a.target = '_blank'
a.click()
}
}
]
if (!readonly) {
actions.push({
label: presentation.string.Delete,
icon: IconDelete,
action: async () => {
const blobs = { ...doc.blobs }
const key = Object.keys(blobs).find((k) => blobs[k] === blob)
if (key) {
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete blobs[key]
await client.update(doc, { blobs })
await deleteFile(blob.file)
}
}
})
}
showPopup(Menu, { actions }, getEventPositionElement(ev))
}
</script>
{#if isFile && Object.keys(doc.blobs ?? {}).length === 0 && !readonly}
@@ -40,6 +86,7 @@
name={blob.name}
metadata={blob.metadata}
fit={blob.type !== 'application/pdf'}
on:contextmenu={(ev) => showContextMenu(blob, ev)}
/>
{/each}
@@ -28,6 +28,7 @@
view.viewlet.List,
view.viewlet.Tree,
view.viewlet.MasterDetail,
card.viewlet.CardGrid,
card.viewlet.CardFeedDescriptor,
view.viewlet.RelationshipTable,
view.viewlet.Document
@@ -37,6 +38,7 @@
view.viewlet.List,
view.viewlet.Tree,
view.viewlet.MasterDetail,
card.viewlet.CardGrid,
card.viewlet.CardFeedDescriptor,
view.viewlet.RelationshipTable
]
+2
View File
@@ -40,6 +40,7 @@ import {
createChildAction
} from './utils'
import { formatCardValue } from './cardTableFormatter'
import CardGridView from './components/CardGridView.svelte'
import ManageMasterTagsContent from './components/settings/ManageMasterTagsContent.svelte'
import ManageMasterTagsTools from './components/settings/ManageMasterTagsTools.svelte'
import ManageMasterTags from './components/settings/ManageMasterTags.svelte'
@@ -135,6 +136,7 @@ export default async (): Promise<Resources> => ({
CardTagsColored,
CardIcon,
CardFeedView,
CardGridView,
CreateSpace,
CardHeaderButton,
CreateRolePopup,
+4 -1
View File
@@ -56,7 +56,8 @@ export default mergeIds(cardId, card, {
CreateCard: '' as AnyComponent,
CardHeaderButton: '' as AnyComponent,
CreateRolePopup: '' as AnyComponent,
MyCards: '' as AnyComponent
MyCards: '' as AnyComponent,
CardGridView: '' as AnyComponent
},
function: {
CardFactory: '' as Resource<(props?: Record<string, any>) => Promise<Ref<Doc> | undefined>>,
@@ -96,6 +97,8 @@ export default mergeIds(cardId, card, {
CardChildList: '' as Ref<Viewlet>,
CardFeedDescriptor: '' as Ref<ViewletDescriptor>,
CardFeed: '' as Ref<Viewlet>,
CardGridDescriptor: '' as Ref<ViewletDescriptor>,
CardGrid: '' as Ref<Viewlet>,
CardRelationshipTable: '' as Ref<Viewlet>
},
string: {
+2
View File
@@ -184,6 +184,7 @@ const cardPlugin = plugin(cardId, {
Feed: '' as Asset,
All: '' as Asset,
Duplicate: '' as Asset,
Grid: '' as Asset,
Lock: '' as Asset
},
extensions: {
@@ -204,6 +205,7 @@ const cardPlugin = plugin(cardId, {
AddTag: '' as IntlString,
Feed: '' as IntlString,
AllCards: '' as IntlString,
Grid: '' as IntlString,
Favorites: '' as IntlString,
CreateCard: '' as IntlString,
AllowCreatingCards: '' as IntlString,
@@ -92,7 +92,7 @@
<div class="flex-between flex-gap-2 h-4">
<div class="flex-row-center flex-gap-2 font-regular-12">
<PersonIdPresenter value={object.createdBy} noUnderline avatarSize="tiny" />
<PersonIdPresenter value={object.createdBy} withPadding={false} noUnderline avatarSize="tiny" />
<span></span>
<span class="flex-no-shrink">
<TimestampPresenter value={version?.lastModified ?? object.createdOn ?? object.modifiedOn} />
@@ -26,6 +26,7 @@
export let noUnderline = false
export let shrink: boolean = false
export let avatarSize: IconSize = 'x-small'
export let withPadding: boolean = true
const client = getClient()
let person: Ref<Person> | undefined
@@ -39,7 +40,7 @@
</script>
{#if person}
<div class="pl-3">
<div class={withPadding ? 'pl-3' : ''}>
<ObjectPresenter
objectId={person}
_class={contact.class.Person}