[nextjs]SNS Server-46(myapp56)Β 

πŸ‘‰πŸ» myapp56μ—μ„œλŠ” νƒ€μž„λΌμΈ 글보기λ₯Ό Home / Local / Federated νƒ­μœΌλ‘œ λ³Ό 수 μžˆμŠ΅λ‹ˆλ‹€.
In myapp56, you can view the timeline under the Home, Local, and Federated tabs.

πŸ‘‰πŸ» 전체 μ½”λ“œλŠ” κΉƒν—ˆλΈŒμ—μ„œ 확인 ν•  수 μžˆμŠ΅λ‹ˆλ‹€.
You can find the full code on GitHub.

https://github.com/gideonslife01/flm-nextjs

πŸ“ 전체 ν”„λ‘œμ νŠΈ ꡬ쑰 / Overall Project Structure

myapp project/  
β”œβ”€β”€ app/  (Next.js App Router)
β”‚   β”œβ”€β”€ .well-known/webfinger/route.ts  -> webfinger
β”‚   β”œβ”€β”€ api/follow/route.ts    -> Follow API(temporary)
β”‚   β”œβ”€β”€ api/announce/route.ts  -> Boost(Announcement)
β”‚   β”œβ”€β”€ api/like/route.ts.     -> Like API
β”‚   β”œβ”€β”€ api/posts/route.ts     -> Writing API(deprecated)
β”‚   β”œβ”€β”€ api/timeline/route.ts  -> Timeline API
β”‚   β”œβ”€β”€ api/followinglist/route.ts  -> followinglist API
β”‚   β”œβ”€β”€ api/followerslist/route.ts  -> followerslist API
β”‚   β”œβ”€β”€ api/v1/instance/route.ts -> auth
β”‚   β”œβ”€β”€ api/v1/apps/route.ts.    -> auth
β”‚   β”œβ”€β”€ api/v1/accounts/verify_credentials/route.ts -> auth
β”‚   β”œβ”€β”€ api/v1/statuses/route.ts -> Write Post
β”‚   β”œβ”€β”€ api/v1/timelines/home/route.ts -> timeline
β”‚   β”œβ”€β”€ api/v1/media/route.ts -> Image upload
β”‚   β”œβ”€β”€ api/v1/media/[id]/route.ts  -> Image Delete 
β”‚   β”œβ”€β”€ api/v1/instance/rules/route.ts -> Feature not implemented.
β”‚   β”œβ”€β”€ api/v1/instance/domain_blocks/route.ts -> Feature not implemented.
β”‚   β”œβ”€β”€ api/v1/filters/route.ts -> Feature not implemented.
β”‚   β”œβ”€β”€ api/v1/lists/route.ts -> Feature not implemented.
β”‚   β”œβ”€β”€ api/v1/announcements/route.ts -> Feature not implemented.
β”‚   β”œβ”€β”€ api/v1/timelines/public/route.ts -> Feature not implemented.
β”‚   β”œβ”€β”€ oauth/authorize/route.ts -> auth
β”‚   β”œβ”€β”€ oauth/token/route.ts -> auth
β”‚   β”œβ”€β”€ oauth/login-and-authorize/route.ts -> Tursky,Pinafore login processing
β”‚   β”œβ”€β”€ oauth/revoke/route.ts -> Feature not implemented.
β”‚   β”œβ”€β”€ api/v1/search/home/route.ts -> search
β”‚   β”œβ”€β”€ api/v2/search/home/route.ts -> search
β”‚   β”œβ”€β”€ api/v2/notifications/policy/route.ts-> Feature not implemented.
β”‚   β”œβ”€β”€ api/v1/accounts/[id]/followers/route.ts -> followers
β”‚   β”œβ”€β”€ api/v1/accounts/[id]/following/route.ts -> following
β”‚   β”œβ”€β”€ api/v1/statuses/[id]/route.ts -> -> Post delete(pinafore)
β”‚   β”œβ”€β”€ api/auth/signup/route.ts        -> Website Signup
β”‚   β”œβ”€β”€ api/auth/login/route.ts         -> Website Login
β”‚   β”œβ”€β”€ api/auth/logout/route.ts        -> Website Logout
β”‚   β”œβ”€β”€ api/auth/refresh/route.ts       -> Refresh Token
β”‚   β”œβ”€β”€ api/cleanup-orphan/route.ts     -> post,outbox clean up
β”‚   β”œβ”€β”€ api/auth/me/route.tsx           -> Login Check
β”‚   β”œβ”€β”€ auth/signup/page.tsx            -> Signup UI
β”‚   β”œβ”€β”€ auth/signup/page.tsx            -> Login UI        
β”‚   β”œβ”€β”€ users/[username]/
β”‚   β”‚   β”œβ”€β”€ statuses/[id]/route.ts -> Indivisual Post
β”‚   β”‚   β”œβ”€β”€ route.ts               -> Acotr Information
β”‚   β”‚   β”œβ”€β”€ followers/route.ts     -> Followers List
β”‚   β”‚   β”œβ”€β”€ following/route.ts     -> Following List
β”‚   β”‚   β”œβ”€β”€ inbox/route.ts         -> Inbox
β”‚   β”‚   └── outbox/route.ts        -> outbox
β”‚   β”œβ”€β”€ usersui/[username]/
β”‚   β”‚   β”œβ”€β”€ page.tsx               -> Timeline UI(Server, 404)
β”‚   β”‚   β”œβ”€β”€ ClientPage.tsx.        -> Timeline UI(Client,Call Active Themes)
β”‚   β”‚   └── _components/themes/
β”‚   β”‚       β”œβ”€β”€ themeex/ThemeexTheme.tsx   -> Example Theme
β”‚   β”‚       β”œβ”€β”€ pinafore/PinaforeTheme.tsx -> Theme 1
β”‚   β”‚       β”œβ”€β”€ pinafore/FollowersList.tsx -> Theme 1, FollowersList
β”‚   β”‚       β”œβ”€β”€ pinafore/FollowingList.tsx -> Theme 1, FollowingList
β”‚   β”‚       β”œβ”€β”€ pinafore/NotificationsList.tsx -> Theme 1, NotificationsList
β”‚   β”‚       β”œβ”€β”€ pinafore/TimelineList.tsx  -> Theme 1, TimelineList
β”‚   β”‚       β”œβ”€β”€ mastodon/MastodonTheme.tsx -> Theme 2
β”‚   β”‚       └── minimal/MinimaltTheme.tsx  -> Theme 3
β”‚   β”œβ”€β”€ layout.tsx, page.tsx, globals.css
β”‚   └── favicon.ico
β”œβ”€β”€ lib/
β”‚   β”œβ”€β”€ theme.tsx              -> Theme Provider
β”‚   β”œβ”€β”€ watchThemes.ts         -> Check real-time theme changes
β”‚   β”œβ”€β”€ auth.ts                -> Authentication, User Management
β”‚   β”œβ”€β”€ ap.ts                  -> Follow,Undo,Create,Likes,Announce
β”‚   β”œβ”€β”€ visibility.ts          -> Visibility(Public,Unlisted,Private,Direct)
β”‚   └── db.ts                  -> DB connection
β”œβ”€β”€ data/
β”‚   β”œβ”€β”€ keys/userIDs/          -> private.pem, public.pem(New)
β”‚   └── keys/                  -> private.pem, public.pem(legacy)
β”œβ”€β”€ data.sqlite                -> Database(1/3)
β”œβ”€β”€ data.sqlite-wal            -> Database(2/3)
β”œβ”€β”€ data.sqlite-shm            -> Database(3/3)
β”œβ”€β”€ Caddyfile                  -> https 
β”œβ”€β”€ instrumentation.ts         -> Background Server
└── package.json

πŸ“ ν”„λ‘œμ νŠΈ μ‹œμž‘ / Project Start


πŸ“ κΈ°λŠ₯μ„€λͺ… / Feature Description

βœ”οΈ Home

— νŽ˜μ΄μ§€ μ†Œμœ μžκ°€ 둜그인 ν•œ 경우 public, unlisted, private λͺ¨λ‘ λ³Ό 수 있음
If the page owner is logged in, they can view public, unlisted, and private content.

— λ‚΄κ°€ νŒ”λ‘œμš° ν•œ μ‚¬λžŒ 글을 λ³Ό 수 있음.(λ‚΄ μ„œλ²„λ‘œ λ°°λ‹¬λœ κΈ€)
You can view posts from people you follow (posts delivered to your server).

— UnlistedλŠ” Localκ³Ό Pederated에 곡개 μ•ˆλ¨
Unlisted items are not visible in the Local or Federated timelines.

βœ”οΈ Local

— λ‚΄ μ„œλ²„μ˜ Public글은 μ „λΆ€ λ³΄μž„.
All public posts on my server are visible.

βœ”οΈ Federated

— λ‚΄ μ„œλ²„μ™€ μ—°κ²°λœ λ‹€λ₯Έ μ„œλ²„μ˜ Public글은 μ „λΆ€ λ³΄μž„.
All public posts from other servers connected to my server are visible.

βœ”οΈ Private

— νŽ˜μ΄μ§€ μ†Œμœ μžλ₯Ό νŒ”λ‘œμš° ν•œ μ‚¬λžŒλ§Œ λ³Ό 수 있음
Visible only to people who follow the page owner.

βœ”οΈ Direct

— νŽ˜μ΄μ§€ μ†Œμœ μž+λ©˜μ…˜λœ μ‚¬λžŒμ΄ λ³Ό 수 있음.
Visible to the page owner and mentioned individuals.


πŸ“ μ½”λ“œμˆ˜μ • / Code Modification

πŸ’‘ μ•„λž˜ λΌμš°νŠΈλŠ” κ΅¬ν˜„λ˜μ§€ μ•Šμ€ λ§ˆμŠ€ν† λˆ ν˜Έν™˜ 라우트둜 κ°€μž₯ λ‚˜μ€‘μ— κ΅¬ν˜„ μ˜ˆμ •
The route below is a Mastodon-compatible route that has not yet been implemented and is scheduled for implementation last.

api/v1/instance/rules/route.ts 
api/v1/instance/domain_blocks/route.ts
api/v1/filters/route.ts
api/v1/lists/route.ts 
api/v1/announcements/route.ts
api/v1/timelines/public/route.ts
oauth/login-and-authorize/route.ts
api/v2/notifications/policy/route.ts

βœ”οΈ APIμˆ˜μ • / API Modification

— app/api/timeline/route.ts – μ½”λ“œ μˆ˜μ • / Code Modification

... ...
export async function GET(req: Request) {
... ...
const type = searchParams.get('type') || 'home'; // βœ… myapp56 - add : home, local, federated
... ...
  // βœ… myapp56 - add type: home, local, federated
  let rawTimeline: any[] = [];

  if (type === 'local') {
    // - Local: My server posts( visibility == public )
    rawTimeline = db.prepare(`
      SELECT id, content, username, username as actor, created_at, visibility, 'Note' as type, 'mine' as source, id as original_id
      FROM posts WHERE visibility='public'
      ORDER BY created_at DESC LIMIT 80
    `).all() as any[];

  } else if (type === 'federated') {
    // - Federated: posts + inbox_posts (visibivlity == public)
    rawTimeline = db.prepare(`
      SELECT id, content, username, username as actor, created_at, visibility, 'Note' as type, 'mine' as source, id as original_id
      FROM posts WHERE visibility='public'
      UNION ALL
      SELECT id, content, username, actor, created_at, visibility, type, 'inbox' as source, original_id
      FROM inbox_posts WHERE visibility='public'
      ORDER BY created_at DESC LIMIT 80
    `).all() as any[];

  } else {
    // - Home: λ‚΄ νƒ€μž„λΌμΈ/My timeline + inbox_posts + posts
    rawTimeline = db.prepare(`
      SELECT id, content, username, username as actor, created_at, visibility, 'Note' as type, 'mine' as source, id as original_id
      FROM posts WHERE username =?
      UNION ALL
      SELECT id, content, username, actor, created_at, visibility, type, 'inbox' as source, original_id
      FROM inbox_posts WHERE username =?
      ORDER BY created_at DESC LIMIT 80
    `).all(username, username) as any[];
  }
... ...
}

βœ”οΈ ν…Œλ§ˆ 파일 μˆ˜μ • / Edit theme files

— app/usersui/[username]/_components/themes/pinafore/TimelineList.tsx

// βœ… myapp56 - Hom,Local,Federated νƒ­ μΆ”κ°€ / Add Hom, Local, Federated Tabs
type TabType = 'home' | 'local' | 'federated';
type Props = {
  timeline: any[];
  currentUser: string | null;
  tab: TabType;
  onTabChange: (t: TabType) => void;
  onDelete: (id: string) => void;
  onBoost: (p: any) => void;
  onLike: (p: any) => void;
  onShare: (p: any) => void;
};

export default function TimelineList({ 
  timeline, currentUser, tab, onTabChange, onDelete, onBoost, onLike, onShare }: Props) {
  const [showDropdown, setShowDropdown] = useState(false); // βœ… myapp56 - Hom,Local,Federated νƒ­ μΆ”κ°€ / Add Hom, Local, Federated Tabs

... ...
  return (
    <>
      <style>{`
             /* βœ… myapp56 - timeline source tabs */
            .timeline-dropdown-wrap {
              display: flex;
              justify-content: space-between;
              align-items: center;
              padding: 10px 16px;
              border-bottom: 1px solid #e6ecf0;
              background: white;
              position: sticky;
              top: 0;
              z-index: 5;
            }
            .timeline-dropdown { position: relative; }
            .dropdown-toggle {
              display: flex;
              align-items: center;
              gap: 8px;
              padding: 8px 14px;
              border: 1px solid #e6ecf0;
              border-radius: 20px;
              background: white;
              cursor: pointer;
              font-size: 14px;
              font-weight: 600;
            }
            .dropdown-toggle:hover { background: #f3f4f6; }
            .dropdown-arrow { font-size: 10px; color: #999; margin-left: 4px; }
            .dropdown-menu {
              position: absolute;
              top: 100%;
              left: 0;
              margin-top: 8px;
              width: 220px;
              background: white;
              border: 1px solid #e6ecf0;
              border-radius: 12px;
              box-shadow: 0 8px 24px rgba(0,0,0,0.12);
              overflow: hidden;
              z-index: 10;
            }
            .dropdown-menu button {
              width: 100%;
              display: flex;
              gap: 12px;
              align-items: center;
              padding: 12px 14px;
              border: none;
              background: white;
              text-align: left;
              cursor: pointer;
            }
            .dropdown-menu button:hover { background: #f7f9fa; }
            .dropdown-menu button.active { background: #fafafe; }
            .dropdown-menu button span:first-child { font-size: 18px; }
            .dropdown-menu button div { display: flex; flex-direction: column; }
            .dropdown-menu button b { font-size: 14px; }
            .dropdown-menu button small { font-size: 11px; color: #657786; }
            .timeline-count { font-size: 12px; color: #999; }
      
      `}</style>

      {/* βœ… myapp56 - Hom,Local,Federated νƒ­ μΆ”κ°€ / Add Hom, Local, Federated Tabs */}
      <div className="timeline-dropdown-wrap">
        <div className="timeline-dropdown">
          <button className="dropdown-toggle" onClick={()=>setShowDropdown(!showDropdown)}>
            <span>{tab==='home'?'🏠': tab==='local'?'🏘':'🌍'}</span>
            <span>{tab}</span>
            <span>{showDropdown?'β–²':'β–Ό'}</span>
          </button>
          {showDropdown && (
            <div className="dropdown-menu">
              <button className={tab==='home'?'active':''} onClick={()=>{onTabChange('home'); setShowDropdown(false);}}>🏠 Home</button>
              <button className={tab==='local'?'active':''} onClick={()=>{onTabChange('local'); setShowDropdown(false);}}>🏘 Local</button>
              <button className={tab==='federated'?'active':''} onClick={()=>{onTabChange('federated'); setShowDropdown(false);}}>🌍 Federated</button>
            </div>
          )}
        </div>
        <div className="timeline-count">{timeline.length} toots</div>
      </div>

      {timeline.map((p: any) => (
        <article key={`${p.source}-${p.id}`} className="pinafore-status">
          <Avatar actor={p.actor} username={p.username} />
          <div className="status-content">
            <div className="status-header">
              <b>{getDisplayName(p.actor)}</b>
              <span className="status-time">{new Date(p.created_at).toLocaleString()}</span>
              {p.isBoostedPost && <span className="boost-label">πŸ” {getDisplayName(p.actor)}</span>}
              {currentUser && p.isMine && (
                <button onClick={() => onDelete(p.id)} style={{ color: '#e0245e', marginLeft: 'auto' }}>
                  πŸ—‘ Delete
                </button>
              )}
            </div>
            <div className="status-text" dangerouslySetInnerHTML={{ __html: p.content }} />
            {p.media_attachments && p.media_attachments.length > 0 && (
              <div className={`status-media ${p.media_attachments.length === 1 ? 'single' : ''}`}>
                {p.media_attachments.map((m: any) => (
                  <img key={m.id} src={toAbsolute(m.preview_url || m.url)} alt="media" loading="lazy" />
                ))}
              </div>
            )}
            <div className="status-actions">
              <button onClick={() => onBoost(p)} className={p.isMyBoost ? 'boosted' : ''}>πŸ” {p.boostCount || ''}</button>
              <button onClick={() => onLike(p)} className={p.isMyLike ? 'liked' : ''}>⭐ {p.likeCount || ''}</button>
              <button onClick={() => onShare(p)}>✈</button>
              <button>πŸ’¬</button>
            </div>
          </div>
        </article>
      ))}
    </>
  );

}

— app/usersui/[username]/_components/themes/pinafore/PinaforeTheme.tsx

  
// βœ… myapp56 - Home, Local, Federated νƒ­ 선택 μƒνƒœ / Home, Local, Federated tab selection state
  const [tab, setTab] = useState<'home'|'local'|'federated'>('home');
... ...  
// βœ… myapp56 
  // - Home / Local / Federated νƒ­ 둜직 
  // / Home / Local / Federated tab logic
  useEffect(() => {
    if (view !== 'timeline') return;
    // ν”„λ‘œν•„ νŽ˜μ΄μ§€λ©΄ username 기반, μ•„λ‹ˆλ©΄ type 기반
    // If it's a profile page, use username-based; otherwise, use type-based.
    const url = tab === 'home' 
      ? `/api/timeline?username=${username}&type=home`
      : `/api/timeline?type=${tab}`;

    fetch(url, { credentials: 'include', cache: 'no-store' })
      .then(r => r.json())
      .then(data => setTimeline(data))
      .catch(e => console.error('tab fetch fail', e));
  }, [tab, username, view]);

  return (
    <> ... ...

          {view === 'timeline' && (
            <>
            {/* βœ… myapp56 - ADD Dropdown(Home/Local/Federated Tabs ) */}

              <TimelineList
                timeline={timeline}
                tab={tab}
                onTabChange={setTab}
                currentUser={currentUser}
                onDelete={handleDelete}
                onBoost={handleBoostClick}
                onLike={handleLikeClick}
                onShare={onShare}
              />
            </>
          )} {/* end view === 'timeline' */}

   ... ... </>

 );

βœ”οΈ lib/visibility.ts – visibility κ·œμΉ™μ— 맞게 μˆ˜μ • / Modify to comply with visibility rules.

export function canSee(viewer: string | null, post: any): boolean {
  const vis = (post.visibility || 'public') as Visibility;
  
  if (vis === 'public') return true;
  if (vis === 'unlisted') return true; // λˆ„κ΅¬λ‚˜ λ³Ό 수 있음 (읡λͺ… 포함) / Anyone can see (including anonymous)

  if (!viewer) return false; // private, directλŠ” 둜그인 ν•„μš” / Login required for private, direct

  if (post.username === viewer) return true; // λ‚΄ 글은 λ‹€ λ³΄μž„ / I can see all my posts

  if (vis === 'private') {
    return isFollower(viewer, post.username); // νŒ”λ‘œμ›Œλ§Œ / Only followers can see
  }
  
  if (vis === 'direct') {
    // λ©˜μ…˜λœ μ‚¬λžŒλ§Œ / Only mentioned users can see
    const mentions = post.mentions || [];
    return mentions.includes(viewer);
  }

  return false;
}

πŸ“ ν…ŒμŠ€νŠΈ / Test

βœ”οΈ Hom,Local,Federated

Home
Local
Federated

βœ”οΈ λ“œλ‘­λ‹€μš΄ 메뉴 / Dropdown menu

Menu

μš”ν•œλ³΅μŒ 8μž₯ 32절 / John 8:32

“그리고 λ„ˆν¬λŠ” 진리λ₯Ό μ•Œκ²Œ 될 것이며, 진리가 λ„ˆν¬λ₯Ό 자유둭게 ν•  것이닀.”

“Then you will know the truth ,and the truth will set you free”

Leave a Reply