一个基于 Vue3 & Vite 的相册应用

由于有若干个能拍照的设备,再加上时间会公平地杀死一切,我每年都会拍出几张回看时感慨万千的照片。

但由于时间与地域的关系,这些照片往往要么丢失,要么被随意塞在网盘的某处。于是,现在 (2022-11-28) 我决心花一些精力把它们维护起来。

首先

经过约 0.37s 的思考,我决定构建一个 SPA 来收纳这些图片。不一会儿,一个具象的 SPA 在脑海里浮现:

  • 分为缩略、详情两种展示状态
  • 图片属性包括
    • 名称
    • 拍摄时间
    • 拍摄地点
    • 一些描述
  • 缩略展示时
    • 初始不展示图片属性
    • 悬浮时暗角遮罩层、属性显示
      • 属性分三层展示:名称、地点 & 时间、描述
      • 描述超出宽度时呈 Ellipsis 效果
      • 属性绝对定位到左下角
    • 悬浮时图像卡片整体稍微增大
    • 采用瀑布流布局
  • 点击缩略图片进入详情
    • 详情页分为两部分:
      • 图片部分
        • 占大部分空间
        • 图片以 object-fit: contain 的方式自适应在容器里
      • 属性部分
        • 占少部分空间
        • 没有描述时添加默认值
    • 支持快捷键操作:
      • 左右翻页
      • ESC 退出详情
    • backdrop-filter 的浸透、模糊效果拉满
  • 响应式调整
  • 适配暗黑主题

同时,我想出一个炫酷简陋的 Logo:

接着

接着就进入奋力编码的阶段了。

缩略展示


图像卡片

最终,每个卡片大致长这样:

是的,这很简单:

<div class="container">
    <img :src="src" @mouseenter="mask = true"/>
    <div class="mask" v-if="mask" @mouseleave="mask = false">
        <div class="info">
            <h3>{{ title }}</h3>
            <p>
                <span v-if="location">
                    <i class="fas fa-map-marker-alt"></i>
                    {{ location }}
                </span>
                <span v-if="year">
                    <i class="fas fa-clock"></i>
                    {{ year }}
                </span>
            </p>
            <p>{{ description }}</p>
        </div>
    </div>
</div>

遮罩层暗角所依赖的样式:

background: radial-gradient(rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0.5) 100%),
            radial-gradient(rgba(0, 0, 0, 0) 33%, rgba(0, 0, 0, 0.3) 166%);

出于一些没有意义的执着,我还想要个 Loading 效果,最好能和每次打开 unsplash 看到的一样。经过不懈努力,我终于发现了 blurha.sh,一个用来生成模糊化占位图的不二之选。

它可以将任意图片,编码成形如 UCIrKH4.x^E09H~X%KNF~XITRjxbxoM{kER% 的一串 Hash 值,反向构建时,需先将此值解码成指定大小的图片,在 Canvas 中拉伸后即可得到一个理想的模糊化占位图。解码、渲染大致就像这样:

import { decode } from 'blurhash';

const pixels = decode(props.blurHash.encoded, 32, 32);
const ctx = skeletonRef.value.getContext('2d');
const imageData = ctx.createImageData(32, 32);
imageData.data.set(pixels);
ctx.putImageData(imageData, 0, 0);

最终,卡片组件变成这样:

<div class="container">
    <Transition mode="out-in">
        <Suspense>
        <template #default>
            <ImageAsync :src="src" @mouseenter="mask = true" />
        </template>
        <template #fallback>
            <canvas class="skeleton" ref="skeletonRef" width="32" height="32"></canvas>
        </template>
        </Suspense>
    </Transition>
    <div class="mask" v-if="mask" @mouseleave="mask = false">
        <div class="info">
            <h3>{{ title }}</h3>
            <p>
            <span v-if="location">
                <i class="fas fa-map-marker-alt"></i>
                {{ location }}
            </span>
            <span v-if="year">
                <i class="fas fa-clock"></i>
                {{ year }}
            </span>
            </p>
            <p>{{ description }}</p>
        </div>
    </div>
</div>

其中的异步图片组件:

<template>
    <img :src="src" />
</template>

<script setup>
import { unref } from 'vue'

const props = defineProps({
  src: String,
});

// using another `img` element instead of directly fetching the `src`
// is in order to make sure only one request will be sent.
const img = new Image();
img.src = unref(props.src);
await new Promise((resolve) => {
  img.onload = () => {
    resolve();
  };
});
</script>

效果大概是这样的:

That’s cool.

瀑布流

缩略时的整体布局大致如下:

<main>
    <article>
        <ImageCard 
            class="cell" 
            v-for="img, index in images" 
            :key="index" 
            v-bind="img"
            @click="openDetail(index)"
        />
    </article>
    <footer class="caption"></footer>
</main>

当然,还要佐之一些 CSS 魔法:

article {
  padding: 24px;
  column-count: 4;
  column-gap: 24px;
}

.cell {
  break-inside: avoid;
  margin-bottom: 24px;
}

其中 break-inside: avoid 很重要。

详情展示

详情部分,相对而言要更简单一些,实现主要基于一些老朋友:

  • display: flex
  • object-fit: contain
  • backdrop-filter: saturate(200%) blur(40px)

以及一些新朋友:

  • vueuse - 目前仅用到了 useWindowSize
  • animate.css - 目前仅在 收起/展开 详情时用了 slideIn/slideOut

适配

此外,同样出于一些没有意义的执着,我还拙劣地做了一些适配。主要包括:

  • 屏幕适配,比如:
    @media screen and (min-width: 1400px) {
        article {
            column-count: 4;
        }
    }
    
  • 深浅主题适配,比如:
    @media (prefers-color-scheme: dark) {
        .preface {
            background-color: #343434;
            color: rgba(255, 255, 255, .78);
        }
    }
    

最后

正如前面所言,原图已不易寻觅,目前所得的一些图片大多来自微信朋友圈或微博,很遗憾图片质量已损失太多。

而这些图片拍时多是好天气,所以干脆统称这些照片为 「一些晴朗的日子」

图片目前采用 Github & Vercel 来托管,感谢这个伟大的时代。

相册在这里

That’s it.


-

另外,更多:

  • 2
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 4
    评论
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Kang Tao

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值