특정 사용자 프로필에 배경 영상을 추가할 수 있나요?

현재 특정 사용자 프로필 페이지에 비디오를 추가하려고 시도하고 있습니다. 이렇게 하면 모든 후원자(patron)의 프로필에 특정 배경 비디오가 표시되도록 할 수 있습니다. (조금 과민 반응할 필요는 없습니다. 전체 비디오가 아니라 루프 애니메이션일 뿐이며, Steam 프로필 배경처럼 꽤 그럴듯해 보일 것입니다.)

다음 HTML 및 CSS 코드는 모든 사용자에게 적용되지만, 분명히 우리가 원하는 결과는 아닙니다:

// 이 코드는 "Header" 탭에 들어갑니다
<video playsinline autoplay muted loop id="myVideo" poster="[INSERT LINK]">
	<source src="[INSERT LINK]" type="video/webm">
	<source src="[INSERT LINK]" type="video/mp4">
</video>
#myVideo {
  position: fixed;
  top: 63px;
  min-height: 1080px;
  margin-left: 50vw;
  transform: translate(-50%);
}

.user-content
{
    background: none;
}

.user-main .about.has-background .details {
    padding-bottom: 15px;
}
.user-main .about
{
    margin-bottom: 0px;
}
.user-content-wrapper
{
    background: rgba(var(--secondary-rgb), 0.8);
}

body.category-general을 사용하여 ‘general’ 카테고리의 페이지에만 이미지를 추가하는 것과 달리, 특정 그룹에 속하거나 특정 사용자 이름을 가진 사용자의 프로필 페이지에는 특정 슬러그(slug)가 할당되어 있지 않은 것 같습니다. 우리는 이 분야에 꽤 초보이며 HTML 직접 작업보다는 CSS에 더 익숙하므로, 우리가 원하는 방식으로 작동하도록 만드는 간단하고 편리한 방법이 있는지 확실하지 않습니다.

우리는 사용자 프로필에 그룹 기반의 유사한 슬러그를 추가하는 것이 최선의 접근 방식이라고 생각하지만, 이를 어떻게 구현할지, 그리고 비디오가 올바른 콘텐츠가 있는 페이지에서만 표시되도록 하는 방법을 알지 못합니다. 또한 더 쉬운 다른 방법이 있다면 반드시 이 방식을 사용해야 한다는 생각에 얽매이지 않겠습니다.

예를 들어, 그룹별이 아니라 사용자별로 처리하는 방식도 더 쉬운 경우라면 열려 있습니다.

우리는 비디오를 모든 페이지에 하드코딩하는 것은 피하고 싶으며, 해당 특정 사용자(들)의 페이지에 있을 때만 로드되도록 하기를 원합니다.

수정: 만약 이것이 어떤 영향을 미친다면, 우리가 stable 브랜치를 사용 중이라는 점을 언급해야 할 것 같습니다.

현재 우리의 접근 방식은 canonical 링크를 통해 특정 사용자의 페이지에 있는지 단순히 감지하고, 그렇다면 해당 비디오를 적용하는 것입니다. 이를 위해 다음 코드를 사용하고 있습니다:

<script type="text/discourse-plugin" version="0.8">
    api.onPageChange(() =>{
        determineUser();
    });
    
    function determineUser() {
        var pageURL = document.querySelector("link[rel='canonical']").getAttribute("href");
        var isUserPage = pageURL.includes("https://www.fortressoflies.com/u/");
        document.documentElement.style.setProperty('--currUsername', pageURL);
        if(isUserPage)
        {
            document.documentElement.style.setProperty('--lastUsername', pageURL);
            $('body').css('background-color', '#'+(Math.random()*0xFFFFFF<<0).toString(16));
        }
    }
</script>

하지만 이 방법은 전체 새로고침(full refresh) 시에만 작동하는 것 같습니다. 어떤 이유에서인지 페이지를 이동하며 클릭할 때 --currUsername 속성이 업데이트되지 않습니다. 그 결과, 사용자 페이지에 무작위 색상의 배경을 적용하는 대신, 마지막에 F5 키를 누른 페이지가 사용자 페이지였다면 모든 페이지에 무작위 색상의 배경이 적용되고, 마지막에 F5 키를 누른 페이지가 사용자 페이지가 아니었다면 어떤 페이지에도 배경이 적용되지 않습니다.

솔직히 JavaScript에 대해 충분히 숙련되지 않아서 왜 그런지 알지 못합니다. 제 생각에는 페이지 변경 시 함수가 실행되어야(실제로도 실행됩니다) pageURL 변수가 업데이트되고, 이는 페이지 로드 시 --currUsername 속성이 업데이트되어야 합니다. 하지만 전체 새로고침 시에만 이러한 일이 발생하며, 그렇지 않으면 변수가 변경되지 않는 것으로 보입니다.

여기서 제가 무엇을 잘못하고 있는지 아는 분이 계신가요?

정규화 URL(canonical URL)은 업데이트되지 않는 반면, “og:url” 속성은 업데이트되는 것 같습니다.

문제는 var pageURL = document.querySelector("meta[property='og:url']").getAttribute("content");를 사용하면 메타 태그 자체가 업데이트되기 전에 값이 갱신된다는 점입니다. 즉, 이 코드는 현재 페이지의 URL이 아니라 이전 페이지의 URL을 가져오게 됩니다.

특정 페이지에 콘텐츠를 추가하려면 최선의 선택은 플러그인 아웃렛(plugin-outlet)입니다. 간단히 말해, 플러그인 아웃렛은 Discourse 템플릿에 예약된 공간으로, 여기에 새로운 콘텐츠를 추가할 수 있습니다.

먼저 확인해야 할 것은 대상이 되는 페이지에 플러그인 아웃렛이 존재하는지입니다. 이를 돕는 테마 컴포넌트가 있습니다.

(deprecated) Plugin outlet locations theme component

해당 컴포넌트를 설치한 후, 토글을 켜고 대상이 되는 페이지로 이동하여 사용할 수 있는 아웃렛이 무엇인지 확인하세요. 이 경우, 해당 플러그인 아웃렛이 존재합니다(초록색으로 강조 표시됨).

따라서 우리가 원하는 아웃렛은 above-user-profile입니다.

만약 존재하지 않는다면 어떻게 해야 할까요? 이 경우 최선의 방법은 추가를 요청하거나 코어에 추가하기 위한 PR을 보내는 것입니다. 사용 사례가 타당하다면 대부분 승인됩니다.

어쨌든, 앞서 말했듯이 이 경우 이미 존재합니다. 이제 마크업을 추가하는 방법을 살펴봅시다. 나머지 부분은 위에서 언급한 컴포넌트가 필요하지 않으므로, 플러그인 아웃렛 이름을 이미 알고 있으므로 이제 비활성화해도 됩니다.

테마의 헤더 탭에 다음과 같은 코드를 추가하기만 하면 됩니다.

<script type="text/x-handlebars" data-template-name="/connectors/OUTLET_NAME/SOME_NAME">
  Your markup goes here...
</script>

OUTLET_NAME을 대상이 되는 아웃렛의 이름으로 변경해야 합니다. 그런 다음 SOME_NAME을 이 커스터마이징에 부여할 이름으로 변경하세요. 이름은 아무거나 상관없지만, 설명적으로 짓는 것이 좋습니다. 좋은 관행입니다. 그래서 최종적으로 다음과 같이 됩니다.

<script type="text/x-handlebars" data-template-name="/connectors/above-user-profile/add-profile-videos">
  Your markup goes here...like
  <h1>Hello World!!</h1>
</script>

이것을 시도해보고 어떤 일이 일어나는지 살펴봅시다… 기억하세요, 위의 스니펫은 테마의 common > header 탭에 들어갑니다.

그리고…

그동안 잘 되고 있습니다. 하지만 더 깊이 파고들겠습니다.

비디오가 모든 프로필에 표시되기를 원하지 않고, 특정 조건에 따라만 표시되기를 원합니다. 그러면 어떻게 해야 할까요? 두 가지가 필요합니다. 소비할 데이터와 약간의 JavaScript입니다.

데이터를 찾아보겠습니다. 플러그인 아웃렛이 예약된 공간이라고 했을 때 기억나시나요? 컨텍스트 없이 그것들을 가질 목적은 무엇일까요? 그래서 Discourse는 각 플러그인 아웃렛에 관련 컨텍스트 조각을 전달합니다… 하지만 먼저 한 걸음 뒤로 물러나 보겠습니다. 여기에 이것을 추가할 때

<script type="text/x-handlebars" data-template-name="/connectors/above-user-profile/add-profile-videos">
  Your markup goes here, like...
  <h1>Hello World!!</h1>
</script>

HTML처럼 보이며 스크립트 태그도 있지만, 그 안의 내용은 handlebars 코드로 취급됩니다.

즉, 다음과 같은 것도 할 수 있다는 뜻입니다.

<script type="text/x-handlebars" data-template-name="/connectors/above-user-profile/add-profile-videos">
  {{log this}}
</script>

그리고 브라우저 콘솔을 확인하세요. 아웃렛이 렌더링될 때마다, 즉 사용자 페이지에 있을 때 이것을 볼 수 있습니다.

이 중 도움이 되는 것이 있을까요? 네… 하지만 지금은 아닙니다. 나중에 다시 돌아오겠습니다. 한 걸음 더 뒤로 물러나서 Discourse가 컨텍스트를 아웃렛에 전달하는 방식을 살펴봅시다. Github에서 - 또는 로컬에서 - 아웃렛 이름을 검색하면 이것을 얻게 됩니다.

Repository search results · GitHub

해당 파일을 열어보겠습니다. 처음 보이는 것은 이 줄입니다.

그 줄의 마지막 부분을 살펴보세요.

args=(hash model=model)

Discourse가 model을 아웃렛의 인자로 전달하는 것을 볼 수 있습니다. 모든 의도와 목적을 위해, 그리고 단순화를 위해, model = data입니다.

즉, 우리 아웃렛의 인자 중 하나는 model이며, 우리가 원하는 데이터가 들어갈 곳입니다. 그래서 다시 우리 스니펫으로 돌아가보겠습니다.

<script type="text/x-handlebars" data-template-name="/connectors/above-user-profile/add-profile-videos">
  {{log this}}
</script>

그리고 이것으로 변경해 보겠습니다.

<script type="text/x-handlebars" data-template-name="/connectors/above-user-profile/add-profile-videos">
-  {{log this}}
+  {{log args}}
</script>

이제 콘솔에서 이것을 얻게 됩니다.

그 데이터를 탐색하여 필요한 것이 있는지 확인해 보세요. 해당 페이지의 다른 요소에서 사용되는 사용자에 대한 모든 데이터를 포함하고 있으므로 포함되어야 합니다. 그것은 해당 특정 사용자의 사용자 페이지의 "모델"입니다.

거기에서 사용할 수 있는 속성 중 하나는… 드럼롤 :drum: … 사용자가 속한 그룹입니다.

즉, 이렇게 하면

{{log args.model.groups}}

콘솔에 사용자가 속한 모든 그룹을 얻게 됩니다.

좋습니다, 이제 필요한 데이터를 가지고 있으므로 남은 일은 그것에 기반한 조건을 추가하는 것입니다.

같은 스니펫에서 그것을 할 수 있다고 생각할 수 있지만, 불행히도 그럴 수 없습니다. Handlebars는 템플릿 언어입니다. 논리에 대한 지원이 매우, 매우 기본적입니다 - 단순한 참/거짓 조건과 루프를 넘어가는 것은 없습니다. 비교와 같은 다른 작업을 수행할 수 없습니다.

그렇다면 정확히 어디서 그것을 수행할 수 있을까요? 커넥터 클래스(connector class)에서, 꽤 멋져 보이죠… 압니다.

간단히 말해, 커넥터 클래스는 본질적으로 아웃렛에 첨부된 JavaScript 조각입니다. 그것보다 훨씬 더 뉘앙스가 있지만, 지금은 이것만 알면 충분합니다.

자, 하나를 만들어 보겠습니다. 이렇게 합니다.

<script type="text/discourse-plugin" version="0.8">
api.registerConnectorClass('OUTLET_NAME', 'SOME_NAME', {

});
</script>

여기서 OUTLET_NAME과 SOME_NAME은 위에서 사용한 것과 동일해야 합니다. 그래서 변경해 보겠습니다.

<script type="text/discourse-plugin" version="0.8">
api.registerConnectorClass('above-user-profile', 'add-profile-videos', {

});
</script>

이 스니펫도 테마의 common > header 탭에 들어갑니다. 따라서 이제 다음과 같은 것을 가지고 있어야 합니다.

<script type="text/x-handlebars" data-template-name="/connectors/above-user-profile/add-profile-videos">
  {{log args.model.groups}}
</script>

<script type="text/discourse-plugin" version="0.8">
  api.registerConnectorClass('above-user-profile', 'add-profile-videos', {

  });
</script>

커넥터 클래스 내부에서 일부 작업을 수행할 수 있습니다… 하지만… 그것은 일반적인 javaScript 파일과는 다르다는 점을 명심해야 합니다. 더 나은 설명이 부족하지만… 다이어트 중인 Ember 컴포넌트라고 생각하세요. 이 주제에 대해 더 깊이 들어가는 것은 여기서는 약간 벗어난 내용이므로 넘어가겠습니다.

기본적으로 네 가지 메서드가 연결되어 있습니다.

actions는 다음과 같이 동작을 정의할 수 있게 해줍니다.

api.registerConnectorClass("above-user-profile", "add-profile-videos", {
  actions: {
    myAction() {
      // do something
    }
  }
});

그런 다음 버튼이 눌릴 때와 같이 아웃렛 내부에서 해당 동작을 호출할 수 있습니다. 여기서는 이것이 필요하지 않으므로 넘어가겠습니다.

api.registerConnectorClass("above-user-profile", "add-profile-videos", {
  shouldRender(args, component) {
    // return true or false here
  }
});

아웃렛은 프로필 페이지에서만 렌더링되고 현재 다른 요구사항이 없으므로 이것도 사용하지 않을 것입니다. 그러나 아웃렛이 렌더링되기 전에 테스트하려는 모든 조건을 추가하는 데 사용할 수 있습니다. 예를 들어, 현재 사용자의 신뢰 수준(trust-level)이나 그런 것들입니다. 넘어가겠습니다…

api.registerConnectorClass("above-user-profile", "add-profile-videos", {
  setupComponent(args, component) {
    // do something
  }
});

이것이 우리가 집중해야 할 부분입니다. 설정하려는 모든 javaScript 조건이나 변수는 여기에 들어갑니다. 그 안에 더 깊이 들어가기 전에, 완성도를 위해 마지막 메서드를 먼저 다뤄보겠습니다.

api.registerConnectorClass("above-user-profile", "add-profile-videos", {
  teardownComponent(args, component) {
    // do something
  }
});

이것은 아웃렛이 제거될 때 발화됩니다. 따라서 이벤트 리스너 제거와 같이 필요한 모든 정리 작업을 수행할 수 있게 해줍니다.

좋습니다, setupComponent로 돌아가보겠습니다.

api.registerConnectorClass("above-user-profile", "add-profile-videos", {
  setupComponent(args, component) {
    // do something
  }
});

두 가지가 전달되는 것을 볼 수 있습니다. 먼저 args가 있고, 그 다음 component가 있습니다.

여기서 args는 앞서 살펴본 것과 동일한 것입니다. Discourse가 아웃렛에 전달한 컨텍스트 데이터입니다. 따라서 이렇게 하면

api.registerConnectorClass("above-user-profile", "add-profile-videos", {
  setupComponent(args, component) {
    console.log(args.model.groups);
  }
});

이전에 본 것과 동일한 정보를 브라우저 콘솔에서 볼 수 있습니다. 프로필 소유자가 속한 그룹입니다. 여기서 재미있는 부분이 시작됩니다. 이제 데이터를 가지고 있고 올바른 훅(hook)을 가지고 있습니다. 따라서 여기서 원하는 것을何でも 할 수 있습니다. 예를 들어, 비디오가 특정 그룹에 속한 멤버의 프로필에서만 표시되기를 원한다면 이렇게 할 수 있습니다.

  api.registerConnectorClass('above-user-profile', 'add-profile-videos', {
    setupComponent(args, component) {
      const inGroup = [...args.model.groups].filter(g => g.name === TARGET_GROUP)
      const showVideo = inGroup.length ? true : false;

      console.log(showVideo);
    }
  });

staff 그룹에 속한 사용자의 프로필 페이지에서 이것을 시도하면 콘솔에 true가 출력됩니다. 이제 우리가 해야 할 남은 일은 그것을 아웃렛 템플릿에 전달하는 것입니다. 이렇게 할 수 있습니다.

setupComponent에 전달되는 component는 커넥터와 아웃렛 사이에서 공유됩니다. 다음과 같이 컴포넌트에 속성으로 설정하여 아웃렛에 것을 전달할 수 있습니다.

  const TARGET_GROUP = "staff"

  api.registerConnectorClass('above-user-profile', 'add-profile-videos', {
    setupComponent(args, component) {
      const inGroup = [...args.model.groups].filter(g => g.name === TARGET_GROUP)
      const showVideo = inGroup.length ? true : false;
-     console.log(showVideo);
+     component.setProperties({showVideo})
    }
  });

이제 템플릿으로 돌아가서 다음과 같은 것을 하면

{{log showVideo}}

동일한 결과가 출력됩니다. 이제 그것을 Handlebars 조건에 넣고 마크업을 그 안에 다음과 같이 추가합니다.

<script
  type="text/x-handlebars"
  data-template-name="/connectors/above-user-profile/add-profile-videos"
>
  {{#if showVideo}}
    <video playsinline autoplay muted loop id="myVideo" poster="[INSERT LINK]">
  	  <source src="[INSERT LINK]" type="video/webm">
  	  <source src="[INSERT LINK]" type="video/mp4">
    </video>
  {{/if}}
</script>

그런 다음 스태프 사용자의 프로필 페이지를 확인하세요. 비디오가 로드되는 것을 볼 수 있습니다.

스태프 멤버의 프로필에서 다른 곳으로 이동하면 비디오가 사라집니다. staff 그룹에 속하지 않은 사용자의 프로필에는 비디오가 표시되지 않습니다.

자, 이것을 모두 합쳐보겠습니다. 이것은 위에서 본 것과 동일한 내용입니다.

제가 사용한 CSS입니다. common > css 탭

#myVideo {
  position: fixed;
  top: var(--header-offset);
  min-height: 100vh;
  left: 0;
  z-index: -1;
}

.user-content {
  background: none;
}

.user-main {
  padding: 0.5em;
  background: rgba(var(--secondary-rgb), 0.8);
}

// 모바일에서도 원한다면
.mobile-view {
  body[class*="user-"] {
    background: none;
    .user-main,
    .user-content {
      padding: 0.5em;
      background: rgba(var(--secondary-rgb), 0.8);
    }
  }
}

HTML / javaScript / Handlebars. 이것은 테마의 common > header 탭에 들어갑니다.

<script
  type="text/x-handlebars"
  data-template-name="/connectors/above-user-profile/add-profile-videos"
>
  {{#if showVideo}}
    <video playsinline autoplay muted loop id="myVideo" poster="[INSERT LINK]">
  	  <source src="[INSERT LINK]" type="video/webm">
  	  <source src="[INSERT LINK]" type="video/mp4">
    </video>
  {{/if}}
</script>

<script type="text/discourse-plugin" version="0.8">
  const TARGET_GROUP = "staff"

  api.registerConnectorClass('above-user-profile', 'add-profile-videos', {
    setupComponent(args, component) {
      const inGroup = [...args.model.groups].filter(g => g.name === TARGET_GROUP)
      const showVideo = inGroup.length ? true : false;
      component.setProperties({showVideo})
    }
  });
</script>

TARGET_GROUP을 대상이 되는 그룹의 이름으로 변경하고 비디오의 src 속성을 추가하세요.

이 게시글은 좀 길었습니다… 그것을 두려워하지 마세요. 개념을 파악하면, 위에서 한 모든 것은 3~5분 미만으로 수행할 수 있습니다.

여기서 좋은 점은 우리가 논의한 모든 것이 어떤 플러그인 아웃렛에 대해서도 거의 동일하다는 것입니다. 변하는 것은 이름뿐입니다. 따라서 이것은 미래에 수행하려는 모든 플러그인 아웃렛 수정에 적용됩니다.

  1. 아웃렛 이름 찾기
  2. 데이터 가져오기
  3. 커넥터에서 데이터 처리
  4. 템플릿으로 속성 전달

정말 심도 있는 내용인데, 다음 주에 시간이 되면 꼭 자세히 살펴볼게요. 일단 대충 훑어본 바로는, 제 현재 구현 방식(비디오를 모든 페이지에 임베드하고, 스크립트를 사용해 계정명이 특정 값일 때만 사용자 프로필에 비디오를 표시하는 방식)보다 훨씬 나은 것 같아요. 이렇게 자세한 설명을 해주셔서 감사합니다. 시간 나면 바로 적용해볼게요!

자, 결론부터 말씀드리면, 이 접근 방식은 대부분 훌륭하게 작동하며 설명도 정말 좋습니다. 최선을 다해 도와주셔서 정말 감사합니다.

사용자별로 작업하는 것은 간단합니다. 다음과 같이 특정 사용자 이름을 확인하면 됩니다:

      const isUser1 = args.model.username == "User1"
      component.setProperties({isUser1})

하지만 CSS에서 문제가 발생합니다. 이 특정 사용자들 만을 위해 사용자 페이지의 외관을 일부 변경하고 싶습니다.

현재까지는 이전과 동일한 코드를 사용하여 이를 구현하고 있습니다:

<script type="text/discourse-plugin" version="0.8">
    api.onPageChange(() =>{
        window.onload = determineUser();
    });
    
    async function determineUser() {
        await sleep(50);
        var pageURL = document.querySelector("meta[property='og:url']").getAttribute("content");
        var isUserPage = pageURL.includes("https://www.siteurl.com/u/");
        var isUser1 = pageURL.includes("u/User1/");
        document.body.className = document.body.className.replace(" user-page-animated","");

        
        if(isUserPage)
        {
            if(isUser1)
            {
                document.body.className += ' user-page-animated';
            }
        }
    }
    
    function sleep(ms)
    {
        return new Promise(resolve => setTimeout(resolve, ms));
    }
</script>

이 방식은 각新用户마다 “User1” 코드를 단순히 복사해서 붙여넣을 수 있게 해주지만, 페이지 로드 후 50ms의 지연을 기다렸다가 실행되어야 하며, 이는 최종 사용자에게 눈에 띕니다(그리고 이 지연을 제거하면 어떤 이유로든 작동하지 않습니다).

제공해 주신 코드에 이 body 클래스 추가 로직을 통합할 수 있는 방법이 있을까요? 이렇게 하면 비디오가 있는 페이지와 없는 페이지를 다르게 스타일링하는 데 활용할 수 있을 것 같습니다.

그리고 진심으로, 자세한 설명 다시 한번 감사합니다.