NHKが大好きな人のAstroを用いたWebページ制作
はじめに
私はNHKが大好きです!
特にドキュメント72時間という、1つの現場に72時間撮影クルーが待機してそこに偶然居合わせた一般の方々へインタビューするドキュメンタリー番組が好きで毎週観ています!
番組の観覧応募は欠かさずしていて、The Coversの10周年を記念した斉藤 和義さんのスペシャルライブに当選したときは、坂本 龍一さんが生前の最後に演奏されたCR509スタジオで聴けたので本当にうれしかったです。
そこで、NHKの魅力を伝えられるポートフォリオを制作しようと思い立ち、NHKが提供しているAPIを探したところNHK番組表APIを見つけました。
簡易的ではありますが、NHK番組表APIと静的サイトジェネレータとして人気が出てきたAstroを用いたWebページを制作してみましたので、最後までお読みいただければ幸いです。
ポートフォリオ
NHK番組表APIを2つ呼び出して、2023年7月8日10時54分時点での番組を表示しています。
NHK番組表API
NHK番組表APIとは、全国のNHKの放送番組のタイトルや放送時間などの番組情報を提供しているAPIのことです。
ポートフォリオで使用したAPIは以下の2つです。
- Program Info API (Ver.2) → 番組IDを指定することで、現在放送している番組情報を取得
- Now On Air API (Ver.2) → 放送地域、サービス(放送波)を指定することで、現在放送している番組情報を取得
Program Info API (Ver.2)では、
- 現在放送している番組のロゴ
を取得するために以下のコードを書きました。
try {
const responseProgramInfo = await axios.all(
Object.values(service).map(
async (serviceId, index) =>
await axios.get(
`https://api.nhk.or.jp/v2/pg/info/${area.東京}/${serviceId}/${presentId[index]}.json?key=${programListApiKey}`
)
)
);
responseProgramInfo.forEach((response) => {
programInfoList = {
...programInfoList,
...response.data.list,
};
});
programLogos = Object.keys(programInfoList).flatMap((key) =>
// @ts-ignore
programInfoList[key].map((obj: { program_logo: { url: string } }) =>
obj.program_logo && obj.program_logo.url ? obj.program_logo.url : ''
)
);
} catch (error) {
//
}
Now On Air API (Ver.2)では、
- サービスのLサイズのロゴ
- サービス名(例えば、NHK総合1、NHKEテレ1など)
- 現在放送している番組のID(Program Info API (Ver.2)のIDと紐づけて、現在放送している番組のロゴを取得するため)
- 現在放送している番組のタイトル
を取得するために以下のコードを書きました。
try {
const responseNowOnAir = await axios.all(
Object.values(service).map(
async (serviceId) =>
await axios.get(
`https://api.nhk.or.jp/v2/pg/now/${area.東京}/${serviceId}.json?key=${programListApiKey}`
)
)
);
responseNowOnAir.forEach((response) => {
nowOnAirList = { ...nowOnAirList, ...response.data.nowonair_list };
});
} catch (error) {
nowOnAirList = nowOnAirAllDummy;
}
const [logoLurls, serviceNames, presentId, presentTitle] = Object.values(
nowOnAirList
).reduce(
(acc: [string[], string[], string[], string[]], obj: any) => {
acc[0].push(obj.present.service.logo_l.url);
acc[1].push(obj.present.service.name);
acc[2].push(obj.present.id);
acc[3].push(obj.present.title);
return acc;
},
[[], [], [], []] as [string[], string[], string[], string[]]
);
ポートフォリオのWebページへアクセスしたとき、2つのAPIでNHKワンセグ2を除く7つのサービスのリクエストをしています。
Program Info API (Ver.2)の利用回数は300回/日で、Now On Air API (Ver.2)の利用回数制限は1500回/日と決まっています。
したがって、前者では1日43回以上で後者では1日215回以上アクセスされると、現在放送している番組情報を取得できなくなります。
取得できないときは、src/images
配下のダミー画像(現在放送している番組のロゴがnullのときにも使用)とconstants
配下のダミーオブジェクトを代わりに取得するようにしています。
コードの説明
以下のすべてのコードは抜粋していることをご了承ください。
設定ファイル
package.json
Astroは2022年4月4日にベータ版がリリースされたばかりで参考資料が少なく、ChatGPTへコードを質問しても2021年9月までの情報を元にするのでReactのコードだと勘違いしてしまいます。
したがって、以下の2つの方針を取ることにしました。
- 便利な機能はAstroが勧めているインテグレーションをなるべく使うこと
- 静的解析ツールを多く導入して、コードの誤りを限りなく少なくすること
以下に記載したコードでdependencies
とdevDependencies
以外は、npm create astro@latest
を実行したときに書かれるコードと同じなので、その部分は省略しました。
{
"dependencies": {
"@astrojs/tailwind": "^3.1.3",
"astro": "^2.5.5",
"astro-icon": "^0.8.1",
"axios": "^1.4.0",
"nanostores": "^0.9.2",
"sass": "^1.63.3",
"splitting": "^1.0.6",
"stylelint-scss": "^5.0.1",
"tailwindcss": "^3.3.2",
"webfontloader": "^1.6.28"
},
"devDependencies": {
"@markuplint/astro-parser": "^3.7.0",
"@typescript-eslint/parser": "^5.59.11",
"astro-eslint-parser": "^0.14.0",
"eslint": "^8.42.0",
"eslint-plugin-astro": "^0.27.1",
"eslint-plugin-tailwindcss": "^3.12.1",
"markuplint": "^3.10.0",
"postcss-html": "^1.5.0",
"stylelint": "^15.7.0",
"stylelint-config-html": "^1.1.0",
"stylelint-config-standard-scss": "^9.0.0"
}
}
tailwind.config.cjs
Astroの公式はTailwind CSSの使用を勧めているので採用しました。
Tailwind CSSを採用して最も良いなと思った部分は、設定ファイルでデザインシステムを記述できることです。
例えば、設定ファイルでcolorsで指定していないtext-gray
とclass名に書いたときにeslint-plugin-tailwindcss
がClassname 'text-gray' is not a Tailwind CSS class!eslinttailwindcss/no-custom-classname
と警告してくれます。
したがって、制作が共同作業になったときに、設定がバラバラになることを未然に防ぐことができます。
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}'],
future: { hoverOnlyWhenSupported: true },
theme: {
colors: {
darkgray: '#333333', // 文章
sub: '#858585', // 補足文章
critical: '#ef4b4b', // エラー
warning: '#ffc107', // 注意
success: '#28a745', // 処理完了
disabled: '#CECECE', // 無効な要素
on: '#CECECE', // 塗りつぶしの上に使う色
link: '#0085c7', // リンク
focused: '#FF5720', // フォーカス
base: '#ffffff', // 背景色(70%)
main: '#808080', // イメージカラー(25%)
accent: '#000000', // 大事な情報(5%)
},
fontSize: {
sm: '1.4rem', // 14px
rg: '1.6rem', // 16px
lg: '2.2rem', // 22px
xl: '2.8rem', // 28px
exl: '4.6rem', // 46px
},
fontWeight: { th: 100, rg: 300, bd: 600, eb: 800 },
extend: {
fontFamily: {
custom: ['line-seed-jp'],
},
cursor: {
fancy: 'url(hand.cur), pointer',
},
},
},
plugins: [],
};
.env
NHK番組表APIを使用するのでAPIキーが必要です。
コードで文字列
と書かれた部分は、発行したAPIキーの文字列へ置き換えます。
NHK_PROGRAM_LIST_API_KEY=文字列
.markuplintrc.cjs, .stylelintrc.cjs, .eslintrc.cjs, astro.config.mjs
セマンティックHTMLで書けるように、Markuplintを採用しました。
Markuplintは子コンポーネントのHTMLタグを認識できないので、設定ファイルで認識できるようにします。
.stylelintrc.cjs
と.eslintrc.cjs
はAstroと組み合わせるときに書くべきコードとほぼ同じで、astro.config.mjs
も初期値からほぼ変えてないので説明は省略します。
module.exports = {
rules: { 'character-reference': false },
parser: {
'.astro$': '@markuplint/astro-parser',
},
extends: ['markuplint:recommended'],
pretenders: [
{
selector: 'Head',
as: {
element: 'head',
},
},
],
nodeRules: [
{
selector: 'head',
rules: {
'permitted-contents': false,
'required-element': false,
},
},
],
};
module.exports = {
extends: [
'stylelint-config-standard-scss',
'stylelint-config-html/astro',
'stylelint-config-html',
],
rules: {
'property-no-vendor-prefix': null,
},
};
module.exports = {
root: true,
env: {
browser: true,
node: true,
},
extends: [
'plugin:tailwindcss/recommended',
'plugin:astro/recommended',
'eslint:recommended',
],
overrides: [
{
files: ['*.astro'],
parser: 'astro-eslint-parser',
parserOptions: {
parser: '@typescript-eslint/parser',
extraFileExtensions: ['.astro'],
sourceType: 'module',
ecmaVersion: 'latest',
},
rules: {
},
},
{
files: ['*.js', '*.mjs'],
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
},
},
],
parser: '@typescript-eslint/parser',
};
import { defineConfig } from 'astro/config';
import tailwind from '@astrojs/tailwind';
// https://astro.build/config
export default defineConfig({
integrations: [tailwind()],
server: { port: 3001 },
});
requestParameters.js
NHK番組表API Keyは、それぞれのコンポーネントで必要になるので共通化しました。
今回のポートフォリオでは、地域IDは東京で固定なので使っていないのですが、後々制作するかも知れないので書きました。
// NHK番組表API Key
export const programListApiKey = import.meta.env.NHK_PROGRAM_LIST_API_KEY;
// 地域ID
export const area = {
札幌: '010',
函館: '011',
旭川: '012',
...
千葉: '120',
東京: '130',
横浜: '140',
...
宮崎: '450',
鹿児島: '460',
沖縄: '470',
};
// サービスID
export const service = {
NHK総合1: 'g1',
NHKEテレ1: 'e1',
NHKBS1: 's1',
NHKBSプレミアム: 's3',
NHKラジオ第1: 'r1',
NHKラジオ第2: 'r2',
NHKFM: 'r3',
};
フォント
フォントはLINE Seedを採用して、フォントの太さは4種類としました。
フォルダの構成は、以下にしています。
public/fonts/LINESeedJP_OTF_Th.woff2
public/fonts/LINESeedJP_OTF_Rg.woff2
public/fonts/LINESeedJP_OTF_Bd.woff2
public/fonts/LINESeedJP_OTF_Eb.woff2
Layout.astro
でstyleタグ(<style lang="scss" is:global>
)の中へ以下のコードを書くことで、font-familyをLINE Seedで指定しています。
@font-face {
font-family: line-seed-jp;
src: url('/fonts/LINESeedJP_OTF_Th.woff2') format('woff2');
font-weight: 100; // font-thin
font-style: normal;
}
@font-face {
font-family: line-seed-jp;
src: url('/fonts/LINESeedJP_OTF_Rg.woff2') format('woff2');
font-weight: 300; // font-light
font-style: normal;
}
@font-face {
font-family: line-seed-jp;
src: url('/fonts/LINESeedJP_OTF_Bd.woff2') format('woff2');
font-weight: 600; // font-semibold
font-style: normal;
}
@font-face {
font-family: line-seed-jp;
src: url('/fonts/LINESeedJP_OTF_Eb.woff2') format('woff2');
font-weight: 800; // font-extrabold
font-style: normal;
}
srcフォルダ
Layout.astro
NHKのテーマカラーは灰色(#808080
)なので、デフォルトのマウスカーソルの代わりに灰色の円をマウスカーソルにして、ホバーしたときにCSSアニメーションを動作させるようにしました。
以下に灰色の円へマウスカーソルへ変更するコードを掲載します。
(ただし、Markuplintでこのコンテキストでは、要素「body」に要素「style」を含めることはできません
とエラーメッセージが出ているので、後ほどリファクタリングします。)
<body>
<div id="cursor"></div>
<slot />
<style lang="scss" is:global>
html {
font-size: 62.5%; // 1rem = 10px
cursor: none;
}
#cursor {
pointer-events: none;
position: fixed;
top: -0.8rem; // 座標調節(カーソル位置と円の中心を合わせる)
left: -0.8rem; // 座標調節(カーソル位置と円の中心を合わせる)
width: 1.6rem; // カーソルの直径
height: 1.6rem; // カーソルの直径
background: rgb(128 128 128 / 100%);
border-radius: 50%;
z-index: 999;
transition: width 0.13s, height 0.13s, top 0.13s, left 0.13s;
mix-blend-mode: difference;
}
#cursor.hover-max {
top: -2rem; // 大きくなった分の座標調節
left: -2rem; // 大きくなった分の座標調節
width: 4rem; // カーソルの直径
height: 4rem; // カーソルの直径
}
#cursor.hover {
top: -1.6rem; // 大きくなった分の座標調節
left: -1.6rem; // 大きくなった分の座標調節
width: 3.2rem; // カーソルの直径
height: 3.2rem; // カーソルの直径
}
</style>
<script>
// ----------------
// マウスカーソルを小さいドットに変更
// ----------------
// カーソル用のdivを取得
const cursor = document.getElementById('cursor');
const html = document.querySelector('html');
// cursorが存在する場合のみイベントリスナーを設定する
if (cursor) {
if (!('ontouchstart' in window)) {
// buttonタグのみcssでカーソルが非表示とならなかったので、jsで非表示
let buttons = document.querySelectorAll('button');
buttons.forEach((button) => {
button.style.cursor = 'none';
});
// ブラウザのストレージからカーソルの位置を取得
const storedCursorX = localStorage.getItem('cursorX');
const storedCursorY = localStorage.getItem('cursorY');
if (storedCursorX && storedCursorY) {
// ブラウザのストレージに位置情報が保存されている場合、それを反映
cursor.style.transform = `translate(${parseInt(
storedCursorX
)}px, ${parseInt(storedCursorY)}px)`;
}
// マウス移動イベントを設定
document.addEventListener('mousemove', (e) => {
// カーソルの位置をマウスの位置に設定する
const cursorX = e.clientX;
const cursorY = e.clientY;
cursor.style.transform = `translate(${e.clientX}px, ${e.clientY}px)`;
// カーソルの位置情報をブラウザのストレージに保存
localStorage.setItem('cursorX', cursorX.toString());
localStorage.setItem('cursorY', cursorY.toString());
});
// リンク要素を全て取得
const linkElems = document.querySelectorAll('a, button, [data-hover]');
// 各リンク要素に対して処理を行う
linkElems.forEach((linkElem) => {
// @ts-ignore
let hoverTimeout;
// マウスオーバー時にカーソルのスタイルを変更する
linkElem.addEventListener('mouseover', () => {
cursor.classList.add('hover-max');
// @ts-ignore
if (hoverTimeout) {
clearTimeout(hoverTimeout);
}
hoverTimeout = setTimeout(() => {
cursor.classList.add('hover');
}, 130);
});
// マウスアウト時にカーソルのスタイルを元に戻す
linkElem.addEventListener('mouseout', () => {
cursor.classList.remove('hover-max');
cursor.classList.remove('hover');
// @ts-ignore
if (hoverTimeout) {
clearTimeout(hoverTimeout);
}
});
});
} else {
cursor.style.background = 'transparent';
if (html) {
// タッチデバイスでマウスを使う場合、初期値のカーソルを表示
html.style.cursor = 'auto';
}
}
}
</script>
</body>
index.astro
Astroはpropsが使えるので、Now On Air API(Ver.2)で取得した情報をProgramChange
コンポーネントへ渡しています。
data-type-shuffle
は、Type Shuffle Animationを動作させるためのdata属性です。
data-type-shuffle
で最初に文字を"N"、"H"、"K"でランダム表示させるようにしています。
data-max-cell-iterations="5"
はdata-type-shuffle
を動作させる時間を表します。
data-text-slide
はCSSアニメーションで白色の縦帯を左から右へ流すことで、番組タイトルが変わる様子を表現しています。
data-presenttitle={presentTitle}
はあまり見慣れない書き方だと思いますので、フロントマター変数をスクリプトに渡すをご覧いただければと思います。
<Layout head={head}>
<main>
<section>
<div>
<h2
data-type-shuffle
data-max-cell-iterations="5"
class="mb-[1rem] text-xl font-eb"
>
Now On Air
</h2>
<h3
data-text-slide
data-presenttitle={presentTitle}
class="h-[7.2rem] w-full text-rg font-bd"
>
{presentTitle[0]}
</h3>
</div>
<ProgramChange
logoLurls={logoLurls}
serviceNames={serviceNames}
presentId={presentId}
presentTitle={presentTitle}
className={''}
/>
</section>
</main>
</Layout>
ProgramChange.astro
ポートフォリオの中心となるコンポーネントです。
以下にProgramChange.astroのコンポーネントを掲載します。
SVGのアイコンはAstro Iconを使いました。
Astro IconはSVGスプライトとして扱えるので、短いコードで簡単にスタイルを変更できます。
Astro Iconを円(ph:circle-thin
)とTV(eva:tv-outline
)で使いました。
以下JSXライクな部分をすべて掲載します。
<ul
data-ul-diameter
class=`grid aspect-square place-content-center pt-[1rem] ${className}`
>
<li class="col-start-1 row-start-1">
<Icon
data-circle-scale
data-circle-color
name="ph:circle-thin"
class="text-[#FB0D06]"
/>
</li>
<li class="col-start-1 row-start-1 mt-[-1rem] scale-150 text-main">
<Icon name="eva:tv-outline" class="pointer-events-none" />
</li>
<li
class="relative col-start-1 row-start-1 flex scale-110 items-center justify-center"
>
<img
data-programlogos={programLogos}
data-presenttitle={presentTitle}
class="max-[539px]:mt-[-0.7rem] max-[539px]:w-[4.8rem]"
src={`https:${programLogos[0]}`}
decoding="async"
loading="lazy"
width={itemDiameter * 0.67}
height={itemDiameter * 0.67}
alt={presentTitle[0]}
onerror={`this.onerror=null; this.src='/src/images/program-logo-dummy.webp';`}
/>
<img
data-serviceLogoM
class="absolute bottom-[-0.7rem] left-[27%] max-[539px]:bottom-[-0.2rem] max-[539px]:left-[28%] max-[539px]:h-[1.6rem] max-[539px]:w-[3.2rem]"
src="/src/images/serviceLogoM/gtv-200x100.webp"
decoding="async"
loading="lazy"
width="46"
height="23"
alt={serviceNames[0]}
/>
</li>
{
logoLurls.map((logoLurl, index) => (
<li data-transform data-logoLurl-js class="col-start-1 row-start-1">
<img
data-hover
data-test
src={`https:${logoLurl}`}
decoding="async"
loading="lazy"
width={`${itemDiameter / 2}`}
height={`${itemDiameter / 2}`}
class="w-[7.2rem] rounded-full border-[0.6rem] border-main duration-200 ease-linear hover:scale-110 hover:border-0 min-[540px]:w-[10rem] min-[540px]:border-[0.8rem]"
alt={serviceNames[index]}
onerror={`this.onerror=null; this.src='/src/images/serviceLogoL/${logoLurl
.replace('//www.nhk.or.jp/common/img/media/', '')
.replace(/\.png$/, '.webp')}';`}
/>
</li>
))
}
</ul>
当初はCSSをすべてTailwind CSSで書く予定だったのですが、jitモードではクラスに変数を使えないことを途中で知ったので、その部分はScoped CSSで書くことにしました。
特にレスポンシブ対応が難しかったです。
以下にstyleタグの中をすべて掲載します。
<style lang="scss">
$breakpoint: 540px; // ulDiameter + 4rem(左右のページのスペースの2rem)
$logo-l-urls-length: 7; // logoLurls.length
$ul-diameter: 50; // ulDiameter
$item-diameter: 100; // itemDiameter
@for $index from 1 through ($logo-l-urls-length + 3) {
// 「3」は、liタグの最初からスタイルを適用しない要素
[data-transform]:nth-child(#{$index}) {
// $logo-l-urls-length: 7のとき、4から10まで代入
transform: rotate(($index * calc(360 / $logo-l-urls-length) - 295deg))
translate(30vw)
rotate(-($index * calc(360 / $logo-l-urls-length) - 295deg));
@media (min-width: $breakpoint) {
/* stylelint-disable length-zero-no-unit */
transform: rotate(($index * calc(360 / $logo-l-urls-length) - 295deg))
translate((calc($ul-diameter / 2) - calc($item-diameter / 20) + 0rem))
rotate(-($index * calc(360 / $logo-l-urls-length) - 295deg));
/* stylelint-enable length-zero-no-unit */
}
}
}
[data-ul-diameter] {
max-width: 39.8rem;
@media (min-width: $breakpoint) {
/* stylelint-disable-next-line length-zero-no-unit */
max-width: $ul-diameter + 0rem;
}
}
[data-circle-scale] {
$scale-values: (
$breakpoint - 1: 6,
$breakpoint - 21: 5.9,
$breakpoint - 41: 5.6,
$breakpoint - 61: 5.45,
$breakpoint - 81: 5.2,
$breakpoint - 101: 5,
$breakpoint - 121: 4.8,
$breakpoint - 141: 4.6,
$breakpoint - 161: 4.35,
$breakpoint - 181: 4.1,
$breakpoint - 201: 3.85
);
transform: scale(5.5);
@each $breakpoint, $scale in $scale-values {
@media (max-width: $breakpoint) {
transform: scale($scale);
}
}
}
// 初めての画面描写での、番組の最初の要素に適用
.js-logo-l-url-img-active-initial {
border: none;
transform: scale(1.1);
}
.js-logo-l-url-active-animation {
animation-name: center-move-out;
animation-duration: 0.8s; // ここの秒数を変える時、bodyタグのクリック無効化の時間も同じ時間へ変えること
animation-timing-function: ease-in-out;
animation-fill-mode: forwards;
}
@keyframes center-move-out {
50% {
transform: translate(0);
}
100% {
pointer-events: none;
}
}
.js-logo-l-url-img-active-animation {
animation-name: active;
animation-duration: 0.8s; // ここの秒数を変える時、bodyタグのクリック無効化の時間も同じ時間へ変えること
animation-timing-function: ease-in-out;
animation-fill-mode: forwards;
}
@keyframes active {
0% {
border: none;
transform: scale(1.1);
}
100% {
border: none;
transform: scale(1.1);
}
}
.pointer-events-none {
pointer-events: none;
}
</style>
状態管理ツール
子コンポーネントのProgramChange
で7つの内クリック(タップ)された1つのサービスによって、親のindex.astro
に書かれているh3タグの中身の{presentTitle[0]}
を変更する必要があります。
AstroはReactのようにonClick内にコールバック関数を書いて、子から親へ値を渡すことができません。
そこで、AstroのShare State Between Islandsで書かれているように、状態管理ツールでNano Storesを採用して対処することにしました。
7つのサービスにそれぞれ0から6までの数字を付与して、その数字を親へNano Storesを用いて渡します。
以下に状態管理の元となるコードを掲載します。
import { atom } from 'nanostores';
export const $programChangeIndex = atom(0);
以下に子コンポーネントのProgramChange
でstoreに関係するコードを記載します。(なお、...は省略を意味します。)
<script>
import { $programChangeIndex } from '../store/store'; // storeから$programChangeIndexをインポート
...
if (circleColor) {
...
// 各li要素に対してクリックイベントを設定
initialNode.forEach((element, index) => {
...
element.addEventListener('click', () => {
$programChangeIndex.set(index); // クリックしたインデックスをセット
...
});
});
}
</script>
以下に親のindex.astro
へ、子コンポーネントでクリックしたサービスがstoreへセットされたことを検知して、{presentTitle[0]}
を変更するコードを記載します。
<script>
import { $programChangeIndex } from '../store/store';
...
$programChangeIndex.subscribe((index) => {
const textslides = document.querySelectorAll('[data-text-slide]');
textslides.forEach((textslide) => {
textslide.classList.add('text-slide');
});
if (presenttitle) {
setTimeout(() => {
// ProgramChangeコンポーネントで、クリックされた現在の番組名を変更
presenttitle.textContent = presentTitle[index];
textslides.forEach((textslide) => {
textslide.classList.remove('text-slide');
});
}, 800);
}
});
</script>
おわりに
Now On Air API (Ver.2)には、現在放送されている番組だけでなく1つ前の番組や1つ次の番組の情報も含めれていますので、それらも制作したいです。
ゆくゆくは、このWebページを放送史へと変えて10年ごとの情報を一つの画面で表示して、ヘッドレスCMSと連携することでCMS側で文言を更新できるようなアーキテクチャにしたいです。
これから、イベントでNHKハッカソンがあれば参加したいです。
もし、NHKの方がこの記事をご覧になられたら、コメントいただけますと幸いです。
アルサーガパートナーズ株式会社のエンジニアによるテックブログです。11月14(木)20時〜 エンジニア向けセミナーを開催!詳細とご応募は👉️ arsaga.jp/news/pressrelease-cheer-up-project-november-20241114/
Discussion