Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add collage #248

Merged
merged 1 commit into from
May 3, 2024
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 web/package.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"version": "0.4.73",
"version": "0.4.74",
"type": "module",
"scripts": {
"dev": "vite",
Expand Down
7 changes: 6 additions & 1 deletion web/src/core/photo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,12 @@ class Photo {
* @example '2021-01-01T00:00:00.000+09:00'
*/
public get takenAt(): string {
return this.metadata.takenAt || '';
if (!this.metadata.takenAt) return '';
const takenAt = new Date(this.metadata.takenAt);
return `${takenAt.getFullYear()}/${(takenAt.getMonth() + 1).toString().padStart(2, '0')}/${takenAt.getDate().toString().padStart(2, '0')} ${takenAt
.getHours()
.toString()
.padStart(2, '0')}:${takenAt.getMinutes().toString().padStart(2, '0')}:${takenAt.getSeconds().toString().padStart(2, '0')}`;
}
}

Expand Down
12 changes: 12 additions & 0 deletions web/src/icons/lab.icon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Icon } from 'konsta/react';
import { ImLab } from 'react-icons/im';

interface LabIconProps {
size?: number;
}

const LabIcon = ({ size }: LabIconProps) => {
return <Icon ios={<ImLab size={size} />} />;
};

export default LabIcon;
8 changes: 7 additions & 1 deletion web/src/locales/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"root.successfully-downloaded-in-gallery": "Successfully downloaded to gallery",

"root.add-photo": "Add Photo",
"root.download": "Download",
"root.download-all": "Download All",
"root.bug-report": "Bug Report",
"root.feature-request": "Feature Request",
Expand Down Expand Up @@ -72,5 +73,10 @@
"root.settings.override-lens-model": "Override Lens Model",

"privacy-policy": "Privacy Policy",
"term-and-conditions": "Term and Conditions"
"term-and-conditions": "Term and Conditions",
"lab": "Laboratory",

"lab.description": "The experimental function may appear like the wind and disappear without a sound.",
"lab.collage": "Collage",
"lab.collage-description": "Create a collage with multiple photos."
}
8 changes: 7 additions & 1 deletion web/src/locales/translations/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"root.successfully-downloaded-in-gallery": "ギャラリーに保存しました",

"root.add-photo": "写真を追加",
"root.download": "ダウンロード",
"root.download-all": "全てダウンロード",
"root.bug-report": "バグ報告",
"root.feature-request": "機能リクエスト",
Expand Down Expand Up @@ -72,5 +73,10 @@
"root.settings.override-lens-model": "レンズモデルを上書き",

"privacy-policy": "プライバシーポリシー",
"term-and-conditions": "利用規約"
"term-and-conditions": "利用規約",
"lab": "実験室",

"lab.description": "実験室機能は風のように現れ、音もなく消えることがあります。",
"lab.collage": "コラージュ",
"lab.collage-description": "写真をコラージュします。"
}
8 changes: 7 additions & 1 deletion web/src/locales/translations/ko.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"root.successfully-downloaded-in-gallery": "갤러리에 저장되었습니다",

"root.add-photo": "사진 추가",
"root.download": "다운로드",
"root.download-all": "일괄 다운로드",
"root.bug-report": "버그 제보",
"root.feature-request": "기능 제안",
Expand Down Expand Up @@ -73,5 +74,10 @@
"root.settings.override-lens-model": "렌즈 모델 전역 변경",

"privacy-policy": "개인정보 처리방침",
"term-and-conditions": "이용약관"
"term-and-conditions": "이용약관",
"lab": "실험실",

"lab.description": "실험실 기능은 바람처럼 나타났다 소리없이 사라질 수 있습니다.",
"lab.collage": "콜라주",
"lab.collage-description": "여러 장의 사진을 한 장으로 합치는 기능입니다."
}
72 changes: 72 additions & 0 deletions web/src/pages/lab/collage/components/add-photo.button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { ChangeEvent } from 'react';
import { ListButton } from 'konsta/react';
import { useTranslation } from 'react-i18next';
import Photo from '../../../../core/photo';
import AddIcon from '../../../../icons/add.icon';
import { useStore } from '../store';

const AddPhotoButton = () => {
const { t } = useTranslation();
const { photos, setPhotos, setLoading } = useStore();

const onDragEnter = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
};

const onDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
};

const onDragOver = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
};

const onDrop = async (e: React.DragEvent<HTMLDivElement>) => {
setLoading(true);
e.preventDefault();
e.stopPropagation();
const { files } = e.dataTransfer;
if (!files) return;
await Promise.all(Array.from(files).map(Photo.create)).then((newPhotos) => {
setPhotos([...photos, ...newPhotos]);
});
setLoading(false);
};

const onChange = async (event: ChangeEvent<HTMLInputElement>) => {
setLoading(true);
await new Promise((resolve) => setTimeout(resolve, 100));
const { files } = event.target;
if (!files) return;
await Promise.all(Array.from(files).map(Photo.create)).then((newPhotos) => {
setPhotos([...photos, ...newPhotos]);
});
setLoading(false);
};

return (
<>
<input type="file" accept="image/*" onChange={onChange} onClick={(e) => (e.currentTarget.value = '')} multiple hidden />

<ListButton
onDragEnter={onDragEnter}
onDragLeave={onDragLeave}
onDragOver={onDragOver}
onDrop={onDrop}
onClick={() => {
const input: HTMLInputElement | null = document.querySelector('input[type="file"]');
if (input) input.click();
}}
>
<AddIcon size={18} />
<div style={{ width: 4 }} />
{t('root.add-photo')}
</ListButton>
</>
);
};

export default AddPhotoButton;
27 changes: 27 additions & 0 deletions web/src/pages/lab/collage/components/added-photo.list-item.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { ListItem } from 'konsta/react';
import { useStore } from '../store';
import RemoveOnePhotoButton from './remove-one-photo.button';

const AddedPhotoListItem = () => {
const { photos } = useStore();

return (
<>
{photos.map((photo, index) => (
<ListItem
key={index}
media={<img src={photo.thumbnail} alt={photo.file.name} style={{ width: '8rem', height: '6rem', objectFit: 'cover', borderRadius: '0.5rem' }} />}
title={photo.file.name}
text={`${photo.takenAt}`}
footer={
<div className="flex space-x-1 mt-1">
<RemoveOnePhotoButton index={index} />
</div>
}
/>
))}
</>
);
};

export default AddedPhotoListItem;
37 changes: 37 additions & 0 deletions web/src/pages/lab/collage/components/download-one-photo.button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { Icon, ListButton } from 'konsta/react';
import { IoDownloadOutline } from 'react-icons/io5';
import { useStore } from '../store';
import convert from '../../../../core/drawing/convert';
import free from '../../../../core/drawing/free';
import download from '../../../../core/file-system/download';
import * as Root from '../../../../store';
import { useTranslation } from 'react-i18next';
import COLLAGE_FUNC from './theme';

const DownloadOnePhotoButton = () => {
const { t } = useTranslation();
const { exportToJpeg, quality, setLoading } = Root.useStore();
const { photos } = useStore();

return (
<ListButton
onClick={async () => {
setLoading(true);
await new Promise((resolve) => setTimeout(resolve, 100));
const canvas = COLLAGE_FUNC(photos);
const filename = photos[0].file.name.replace(/\.[^/.]+$/, `.${exportToJpeg ? 'jpg' : 'webp'}`);
const data = await convert(canvas, { type: exportToJpeg ? 'image/jpeg' : 'image/webp', quality });
free(canvas);
await download(filename, data);

setLoading(false);
}}
>
<Icon ios={<IoDownloadOutline className="w-5 h-5" />} />
<div style={{ width: 4 }} />
{t('root.download')}
</ListButton>
);
};

export default DownloadOnePhotoButton;
27 changes: 27 additions & 0 deletions web/src/pages/lab/collage/components/remove-one-photo.button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { IoTrashOutline } from 'react-icons/io5';
import { Button, Icon } from 'konsta/react';
import { useStore } from '../store';

interface RemoveOnePhotoButtonProps {
index: number;
}

const RemoveOnePhotoButton: React.FC<RemoveOnePhotoButtonProps> = ({ index }) => {
const { photos, setPhotos } = useStore();

return (
<div className="w-10">
<Button
className="k-color-brand-red"
onClick={() => {
const newPhotos = photos.filter((_, i) => i !== index);
setPhotos(newPhotos);
}}
>
<Icon ios={<IoTrashOutline className="w-5 h-5" />} />
</Button>
</div>
);
};

export default RemoveOnePhotoButton;
47 changes: 47 additions & 0 deletions web/src/pages/lab/collage/components/theme.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import Photo from '../../../../core/photo';

const COLLAGE_FUNC = (photos: Photo[]): HTMLCanvasElement => {
const canvas = document.createElement('canvas');

canvas.width = 4096;
canvas.height = 4096;

const context = canvas.getContext('2d')!;
context.fillStyle = '#ffffff';
context.fillRect(0, 0, canvas.width, canvas.height);

const length = photos.length;
const size = Math.ceil(Math.sqrt(length));
const margin = 30;

const width = (canvas.width - margin * 2 - margin * (size - 1)) / size;
const height = (canvas.height - margin * 2 - margin * (size - 1)) / size;

for (let i = 0; i < length; i++) {
// draw image with center crop
const photo = photos[i];
const image = photo.image;
const x = margin + (i % size) * (width + margin);
const y = margin + Math.floor(i / size) * (height + margin);
const imageWidth = image.width;
const imageHeight = image.height;
const ratio = imageWidth / imageHeight;

if (ratio > width / height) {
const cropWidth = imageHeight * (width / height);
context.drawImage(image, (imageWidth - cropWidth) / 2, 0, cropWidth, imageHeight, x, y, width, height);
} else {
const cropHeight = imageWidth * (height / width);
context.drawImage(image, 0, (imageHeight - cropHeight) / 2, imageWidth, cropHeight, x, y, width, height);
}

// draw border
context.strokeStyle = '#000000';
context.lineWidth = 1;
context.strokeRect(x, y, width, height);
}

return canvas;
};

export default COLLAGE_FUNC;
24 changes: 24 additions & 0 deletions web/src/pages/lab/collage/main.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { BlockTitle, BlockHeader, List } from 'konsta/react';
import { useTranslation } from 'react-i18next';
import AddPhotoButton from './components/add-photo.button';
import AddedPhotoListItem from './components/added-photo.list-item';
import DownloadOnePhotoButton from './components/download-one-photo.button';

const Collage = () => {
const { t } = useTranslation();

return (
<>
<BlockTitle>{t('lab.collage')}</BlockTitle>

<BlockHeader>{t('lab.collage-description')}</BlockHeader>
<List strong inset>
<AddedPhotoListItem />
<AddPhotoButton />
<DownloadOnePhotoButton />
</List>
</>
);
};

export default Collage;
18 changes: 18 additions & 0 deletions web/src/pages/lab/collage/store.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { create } from 'zustand';
import Photo from '../../../core/photo';

type Store = {
photos: Photo[];
setPhotos: (photos: Photo[]) => void;

loading: boolean;
setLoading: (loading: boolean) => void;
};

export const useStore = create<Store>((set) => ({
photos: [],
setPhotos: (photos) => set({ photos }),

loading: false,
setLoading: (loading) => set({ loading }),
}));
21 changes: 21 additions & 0 deletions web/src/pages/lab/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { Navbar, NavbarBackLink, Page } from 'konsta/react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import Collage from './collage/main';

const LabPage = () => {
const navigator = useNavigate();
const { t } = useTranslation();

return (
<>
<Page>
<Navbar title={t('lab')} subtitle={t('lab.description')} left={<NavbarBackLink text={t('back')} onClick={() => navigator(-1)} />} />

<Collage />
</Page>
</>
);
};

export default LabPage;
4 changes: 3 additions & 1 deletion web/src/pages/privacy-policy.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import { Block, BlockTitle, Navbar, NavbarBackLink, Page } from 'konsta/react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';

const PrivacyPolicyPage = () => {
const navigator = useNavigate();
const { t } = useTranslation();

return (
<>
<Page>
<Navbar title="Privacy Policy" left={<NavbarBackLink onClick={() => navigator(-1)} />} />
<Navbar title="Privacy Policy" left={<NavbarBackLink text={t('back')} onClick={() => navigator(-1)} />} />

<BlockTitle>Privacy Policy</BlockTitle>
<Block>
Expand Down
Loading