Skip to content
Open
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
32 changes: 19 additions & 13 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,18 +1,24 @@
# See https://help.github.com/ignore-files/ for more about ignoring files.

# dependencies
/node_modules

# testing
/coverage

# production
/build

# misc
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
node_modules
.next
out
.DS_Store
.env
.env.local
.env.development.local
.env.test.local
.env.production.local

# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*

# Editor directories and files
.idea
.vscode
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
35 changes: 30 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,33 @@
# HQ ICON
# HQ Icon

[![Build Status](https://travis-ci.org/zhangweijie-cn/hq-icon.svg?branch=master)](https://travis-ci.org/zhangweijie-cn/hq-icon)
A modern Next.js (App Router) experience for grabbing high-resolution App Store icons across iOS, macOS, watchOS, and visionOS. Built with TypeScript, Tailwind CSS, and pnpm.

Get high quality icons from App Store.
Use [iTunes Affiliate Resources](https://affiliate.itunes.apple.com/resources/documentation/itunes-store-web-service-search-api/) and Canvas
## Getting started

[demo](http://zhangweijie-cn.github.io/hq-icon)
```bash
pnpm install
pnpm dev
```

Open [http://localhost:3000](http://localhost:3000) to search for any app by name, App Store URL, or ID. Icons can be exported at 512px or 1024px with automatic rounded masking for iOS-style assets.

## Features

- Supports 20+ App Store storefronts
- Filters for iOS, macOS, watchOS, and visionOS apps
- Downloads icons in PNG format at 512px and 1024px
- Short-link expansion for `appsto.re` and `apple.co` URLs
- Built with the latest Next.js 14, React 18, and Tailwind CSS 3

## Deployment

Create a production build with:

```bash
pnpm build
pnpm start
```

## License

MIT
57 changes: 57 additions & 0 deletions app/api/search/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { NextResponse, type NextRequest } from 'next/server';

import { expandShortUrl, fetchApps } from '@/lib/apple';
import type { Platform } from '@/lib/types';

function parsePlatforms(param: string | null): Platform[] {
if (!param) {
return [];
}
const values = param
.split(',')
.map((value) => value.trim())
.filter(Boolean) as Platform[];
const valid: Platform[] = ['iOS', 'macOS', 'watchOS', 'visionOS'];
return values.filter((value): value is Platform => valid.includes(value as Platform));
}

function sanitiseCountry(input: string | null) {
if (!input) return 'US';
const trimmed = input.trim().toUpperCase();
if (/^[A-Z]{2}$/.test(trimmed)) {
return trimmed;
}
return 'US';
}

function isHttpUrl(input: string) {
return /^https?:\/\//i.test(input);
}

export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const rawQuery = searchParams.get('query');
if (!rawQuery) {
return NextResponse.json({ results: [] });
}

let query = rawQuery.trim();
if (!query) {
return NextResponse.json({ results: [] });
}

try {
if (isHttpUrl(query)) {
query = await expandShortUrl(query);
}

const country = sanitiseCountry(searchParams.get('country'));
const platforms = parsePlatforms(searchParams.get('platforms'));
const results = await fetchApps(query, country, platforms);

return NextResponse.json({ results });
} catch (error) {
console.error('[api/search] Failed to fetch apps', error);
return NextResponse.json({ results: [], error: 'SEARCH_FAILED' }, { status: 500 });
}
}
29 changes: 29 additions & 0 deletions app/globals.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

:root {
color-scheme: dark;
}

body {
@apply bg-slate-950 text-slate-100 min-h-screen antialiased;
}

main {
@apply flex-1;
}

@layer components {
.btn-primary {
@apply inline-flex items-center justify-center gap-2 rounded-full bg-sky-500 px-4 py-2 text-sm font-semibold text-white transition hover:bg-sky-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-300 disabled:cursor-not-allowed disabled:opacity-60;
}

.btn-secondary {
@apply inline-flex items-center justify-center gap-2 rounded-full border border-slate-700 bg-slate-900/60 px-4 py-2 text-sm font-semibold text-slate-100 transition hover:border-slate-500 hover:bg-slate-800/80 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-slate-500 disabled:cursor-not-allowed disabled:opacity-60;
}

.pill {
@apply inline-flex items-center gap-1 rounded-full border border-slate-700/80 bg-slate-900/70 px-3 py-1 text-xs font-medium uppercase tracking-wide text-slate-200;
}
}
29 changes: 29 additions & 0 deletions app/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import type { Metadata } from 'next';
import type { ReactNode } from 'react';
import { Inter } from 'next/font/google';

import './globals.css';

const inter = Inter({ subsets: ['latin'], variable: '--font-inter' });

export const metadata: Metadata = {
title: 'HQ Icon',
description:
'Download pixel-perfect App Store icons across iOS, macOS, watchOS, and visionOS storefronts.',
};

export default function RootLayout({
children,
}: {
children: ReactNode;
}) {
return (
<html lang="en" className={`${inter.variable}`}>
<body className="min-h-screen bg-grid-glow">
<div className="flex min-h-screen flex-col">
{children}
</div>
</body>
</html>
);
}
32 changes: 32 additions & 0 deletions app/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import SearchExperience from '@/components/search-experience';

export default function Page() {
return (
<main className="mx-auto flex w-full max-w-6xl flex-1 flex-col gap-12 px-6 pb-16 pt-20 sm:px-10 lg:px-16">
<header className="space-y-6 text-center sm:text-left">
<span className="pill mx-auto sm:mx-0">HQ ICON</span>
<h1 className="text-3xl font-bold leading-tight text-white sm:text-4xl lg:text-5xl">
High-resolution App Store icons across every platform.
</h1>
<p className="mx-auto max-w-3xl text-lg text-slate-300 sm:mx-0">
Paste an App Store link, ID, or app name to instantly fetch sharp icons for iOS,
macOS, watchOS, and visionOS in 512px or 1024px. Built with the latest Next.js,
TypeScript, Tailwind CSS, and powered by pnpm.
</p>
</header>
<SearchExperience />
<footer className="border-t border-white/10 pt-6 text-center text-sm text-slate-500 sm:text-left">
Crafted with ❤️ for designers and developers. Source available on{' '}
<a
href="https://github.com/zhangweijie-cn/hq-icon"
target="_blank"
rel="noreferrer"
className="font-medium text-sky-400 hover:text-sky-300"
>
GitHub
</a>
.
</footer>
</main>
);
}
114 changes: 114 additions & 0 deletions components/app-result-card.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
'use client';

import { useEffect, useMemo, useState } from 'react';

import drawOutline, { type IconResolution } from '@/lib/draw-outline';
import type { AppListing } from '@/lib/types';

interface AppResultCardProps {
app: AppListing;
resolution: IconResolution;
}

function formatFileName(app: AppListing, resolution: IconResolution) {
const platform = app.platforms.join('-').toLowerCase() || 'app';
const safeName = app.trackName.replace(/[\\/:*?"<>|]/g, '').replace(/\s+/g, '-');
return `${safeName}-${platform}-${resolution}x${resolution}.png`;
}

function PlatformBadge({ value }: { value: string }) {
return <span className="pill bg-slate-800/70 text-slate-200">{value}</span>;
}

export default function AppResultCard({ app, resolution }: AppResultCardProps) {
const [iconSrc, setIconSrc] = useState<string>(app.artworkUrl512);
const [isLoading, setIsLoading] = useState(true);

useEffect(() => {
let isCancelled = false;
setIsLoading(true);
drawOutline(app, resolution)
.then((base64) => {
if (!isCancelled) {
setIconSrc(base64);
}
})
.catch(() => {
if (!isCancelled) {
setIconSrc(resolution === 1024 ? app.artworkUrl1024 ?? app.artworkUrl512 : app.artworkUrl512);
}
})
.finally(() => {
if (!isCancelled) {
setIsLoading(false);
}
});

return () => {
isCancelled = true;
};
}, [app, resolution]);

const downloadName = useMemo(() => formatFileName(app, resolution), [app, resolution]);

return (
<article className="group flex flex-col gap-4 rounded-3xl border border-white/10 bg-slate-900/60 p-6 shadow-2xl shadow-sky-500/10 backdrop-blur transition hover:border-sky-500/60 hover:shadow-sky-400/30">
<a
href={iconSrc}
download={downloadName}
className="relative block overflow-hidden rounded-2xl border border-slate-800 bg-slate-950/60"
>
<div className="flex aspect-square items-center justify-center">
{isLoading ? (
<div className="h-24 w-24 animate-spin rounded-full border-4 border-slate-700 border-t-sky-400" />
) : (
<img
src={iconSrc}
alt={app.trackName}
className="h-full w-full object-cover"
loading="lazy"
/>
)}
</div>
<span className="absolute bottom-3 right-3 rounded-full bg-slate-950/80 px-3 py-1 text-xs font-medium text-slate-200 opacity-0 transition group-hover:opacity-100">
Download
</span>
</a>
<div className="space-y-3">
<div className="flex flex-col gap-2">
<div className="flex items-start justify-between gap-3">
<h3 className="text-lg font-semibold text-white">{app.trackName}</h3>
{app.formattedPrice && (
<span className="rounded-full border border-slate-700 px-3 py-1 text-xs font-semibold text-slate-200">
{app.formattedPrice}
</span>
)}
</div>
{app.sellerName && <p className="text-sm text-slate-400">{app.sellerName}</p>}
{app.primaryGenreName && <p className="text-xs uppercase tracking-wide text-slate-500">{app.primaryGenreName}</p>}
</div>
<div className="flex flex-wrap gap-2">
{app.platforms.length > 0 ? (
app.platforms.map((platform) => <PlatformBadge key={platform} value={platform} />)
) : (
<PlatformBadge value="Universal" />
)}
<PlatformBadge value={`${resolution} x ${resolution}`} />
</div>
</div>
<div className="mt-auto flex flex-wrap gap-2">
<a
href={app.url}
target="_blank"
rel="noreferrer"
className="btn-secondary w-full sm:w-auto"
>
View in App Store
</a>
<a href={iconSrc} download={downloadName} className="btn-primary w-full sm:w-auto">
Download PNG
</a>
</div>
</article>
);
}
Loading